import { Test, TestingModule } from '@nestjs/testing';
import { IdempotencyService } from './idempotency.service';
import { IdempotencyRepository } from './idempotency.repository';
import { AppLoggerService } from 'src/common/services/logger.service';
import InifniConflictException from 'src/common/exceptions/infini-conflict-exception';

describe('IdempotencyService', () => {
  let service: IdempotencyService;
  const mockRepository = {
    tryInsertPending: jest.fn(),
    findByKeyAndEndpoint: jest.fn(),
    incrementHitCount: jest.fn(),
    tryReclaimFailed: jest.fn(),
    markCompleted: jest.fn(),
    markFailed: jest.fn(),
  };
  const mockLogger = { log: jest.fn(), error: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        IdempotencyService,
        { provide: IdempotencyRepository, useValue: mockRepository },
        { provide: AppLoggerService, useValue: mockLogger },
      ],
    }).compile();

    service = module.get<IdempotencyService>(IdempotencyService);
  });

  describe('run', () => {
    it('runs the executor directly when no idempotency key is supplied', async () => {
      const executor = jest.fn().mockResolvedValue({ id: 1 });

      const result = await service.run(undefined, 'POST /registration', { a: 1 }, 5, executor);

      expect(result).toEqual({ id: 1 });
      expect(executor).toHaveBeenCalledTimes(1);
      expect(mockRepository.tryInsertPending).not.toHaveBeenCalled();
    });

    it('claims a new key, runs the executor once, and marks it completed', async () => {
      mockRepository.tryInsertPending.mockResolvedValue({ id: 1 });
      const executor = jest.fn().mockResolvedValue({ registrationId: 42 });

      const result = await service.run('key-1', 'POST /registration', { a: 1 }, 5, executor);

      expect(result).toEqual({ registrationId: 42 });
      expect(executor).toHaveBeenCalledTimes(1);
      expect(mockRepository.markCompleted).toHaveBeenCalledWith(1, { registrationId: 42 });
      expect(mockRepository.markFailed).not.toHaveBeenCalled();
      expect(mockRepository.incrementHitCount).not.toHaveBeenCalled();
    });

    it('marks the claimed row failed and rethrows when the executor throws', async () => {
      mockRepository.tryInsertPending.mockResolvedValue({ id: 1 });
      const executor = jest.fn().mockRejectedValue(new Error('boom'));

      await expect(service.run('key-1', 'POST /registration', { a: 1 }, 5, executor)).rejects.toThrow('boom');

      expect(mockRepository.markFailed).toHaveBeenCalledWith(1);
      expect(mockRepository.markCompleted).not.toHaveBeenCalled();
    });

    it('replays the cached response instead of re-running the executor when already completed, and records the hit', async () => {
      mockRepository.tryInsertPending.mockResolvedValue(null);
      mockRepository.findByKeyAndEndpoint.mockResolvedValue({
        id: 2,
        requestHash: service['hashPayload']({ a: 1 }),
        status: 'completed',
        responseData: { registrationId: 42 },
      });
      const executor = jest.fn();

      const result = await service.run('key-1', 'POST /registration', { a: 1 }, 5, executor);

      expect(result).toEqual({ registrationId: 42 });
      expect(executor).not.toHaveBeenCalled();
      expect(mockRepository.findByKeyAndEndpoint).toHaveBeenCalledWith('key-1', 'POST /registration', 5);
      expect(mockRepository.incrementHitCount).toHaveBeenCalledWith(2);
    });

    it('throws a conflict when the same key is reused with a different payload', async () => {
      mockRepository.tryInsertPending.mockResolvedValue(null);
      mockRepository.findByKeyAndEndpoint.mockResolvedValue({
        id: 2,
        requestHash: service['hashPayload']({ a: 'different' }),
        status: 'completed',
        responseData: { registrationId: 42 },
      });
      const executor = jest.fn();

      await expect(service.run('key-1', 'POST /registration', { a: 1 }, 5, executor)).rejects.toBeInstanceOf(
        InifniConflictException,
      );
      expect(executor).not.toHaveBeenCalled();
      expect(mockRepository.incrementHitCount).toHaveBeenCalledWith(2);
    });

    it('throws a conflict when a request with the same key is still in progress', async () => {
      mockRepository.tryInsertPending.mockResolvedValue(null);
      mockRepository.findByKeyAndEndpoint.mockResolvedValue({
        id: 2,
        requestHash: service['hashPayload']({ a: 1 }),
        status: 'pending',
        responseData: null,
      });
      const executor = jest.fn();

      await expect(service.run('key-1', 'POST /registration', { a: 1 }, 5, executor)).rejects.toBeInstanceOf(
        InifniConflictException,
      );
      expect(executor).not.toHaveBeenCalled();
    });

    it('allows a retry when the previous attempt under the same key failed', async () => {
      mockRepository.tryInsertPending.mockResolvedValue(null);
      mockRepository.findByKeyAndEndpoint.mockResolvedValue({
        id: 2,
        requestHash: service['hashPayload']({ a: 1 }),
        status: 'failed',
        responseData: null,
      });
      mockRepository.tryReclaimFailed.mockResolvedValue({ id: 2 });
      const executor = jest.fn().mockResolvedValue({ registrationId: 43 });

      const result = await service.run('key-1', 'POST /registration', { a: 1 }, 5, executor);

      expect(result).toEqual({ registrationId: 43 });
      expect(executor).toHaveBeenCalledTimes(1);
      expect(mockRepository.markCompleted).toHaveBeenCalledWith(2, { registrationId: 43 });
      expect(mockRepository.tryReclaimFailed).toHaveBeenCalledWith('key-1', 'POST /registration', expect.any(String), 5);
    });

    it('allows a retry with a corrected payload when the previous attempt under the same key failed', async () => {
      mockRepository.tryInsertPending.mockResolvedValue(null);
      mockRepository.findByKeyAndEndpoint.mockResolvedValue({
        id: 2,
        requestHash: service['hashPayload']({ a: 'original' }),
        status: 'failed',
        responseData: null,
      });
      mockRepository.tryReclaimFailed.mockResolvedValue({ id: 2 });
      const executor = jest.fn().mockResolvedValue({ registrationId: 44 });

      const result = await service.run('key-1', 'POST /registration', { a: 'corrected' }, 5, executor);

      expect(result).toEqual({ registrationId: 44 });
      expect(executor).toHaveBeenCalledTimes(1);
      expect(mockRepository.markCompleted).toHaveBeenCalledWith(2, { registrationId: 44 });
      expect(mockRepository.tryReclaimFailed).toHaveBeenCalledWith(
        'key-1',
        'POST /registration',
        service['hashPayload']({ a: 'corrected' }),
        5,
      );
    });

    it('conflicts (does not run the executor) when reclaiming a failed row loses the race', async () => {
      mockRepository.tryInsertPending.mockResolvedValue(null);
      mockRepository.findByKeyAndEndpoint.mockResolvedValue({
        id: 2,
        requestHash: service['hashPayload']({ a: 1 }),
        status: 'failed',
        responseData: null,
      });
      mockRepository.tryReclaimFailed.mockResolvedValue(null);
      const executor = jest.fn();

      await expect(service.run('key-1', 'POST /registration', { a: 1 }, 5, executor)).rejects.toBeInstanceOf(
        InifniConflictException,
      );
      expect(executor).not.toHaveBeenCalled();
    });
  });
});
