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

const { mockRedisSet, mockRedisEval } = vi.hoisted(() => ({
  mockRedisSet: vi.fn(),
  mockRedisEval: vi.fn(),
}));

const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() }));

const { mockGenerateMonthlyInvoice } = vi.hoisted(() => ({
  mockGenerateMonthlyInvoice: vi.fn(),
}));

vi.mock('../redis.js', () => ({
  default: { set: mockRedisSet, eval: mockRedisEval },
}));

vi.mock('../db.js', () => ({
  getPool: () => ({ query: mockQuery }),
}));

vi.mock('./invoices.js', () => ({
  generateMonthlyInvoice: (...args: any[]) => mockGenerateMonthlyInvoice(...args),
}));

import { runInvoiceCron } from './cron.js';

const TWO_CUSTOMERS = [[{ id: 'cust-a' }, { id: 'cust-b' }]];

beforeEach(() => {
  vi.clearAllMocks();
  mockRedisEval.mockResolvedValue(1);
  mockGenerateMonthlyInvoice.mockResolvedValue(null);
});

describe('runInvoiceCron — Redis lock', () => {
  it('runs invoice loop when lock is acquired', async () => {
    mockRedisSet.mockResolvedValue('OK');
    mockQuery.mockResolvedValue(TWO_CUSTOMERS);

    await runInvoiceCron();

    expect(mockRedisSet).toHaveBeenCalledWith(
      'invoice:cron:lock',
      expect.any(String),
      { NX: true, EX: 3600 }
    );
    expect(mockQuery).toHaveBeenCalled();
  });

  it('skips invoice loop when lock is already held', async () => {
    mockRedisSet.mockResolvedValue(null);

    await runInvoiceCron();

    expect(mockQuery).not.toHaveBeenCalled();
    expect(mockGenerateMonthlyInvoice).not.toHaveBeenCalled();
  });

  it('releases lock via compare-delete Lua script after success', async () => {
    mockRedisSet.mockResolvedValue('OK');
    mockQuery.mockResolvedValue([[{ id: 'cust-1' }]]);

    await runInvoiceCron();

    expect(mockRedisEval).toHaveBeenCalledWith(
      expect.stringContaining('redis.call("get"'),
      expect.objectContaining({ keys: ['invoice:cron:lock'], arguments: [expect.any(String)] })
    );
  });

  it('does NOT release lock when the loop throws', async () => {
    mockRedisSet.mockResolvedValue('OK');
    mockQuery.mockRejectedValue(new Error('DB down'));

    await expect(runInvoiceCron()).rejects.toThrow('DB down');
    expect(mockRedisEval).not.toHaveBeenCalled();
  });
});

describe('runInvoiceCron — per-customer error isolation', () => {
  it('continues processing remaining customers after one fails', async () => {
    mockRedisSet.mockResolvedValue('OK');
    mockQuery.mockResolvedValue([[{ id: 'cust-a' }, { id: 'cust-b' }, { id: 'cust-c' }]]);

    mockGenerateMonthlyInvoice
      .mockResolvedValueOnce({ invoice_number: 'SMCP-1', customer_id: 'cust-a' })
      .mockRejectedValueOnce(new Error('Stripe error'))
      .mockResolvedValueOnce({ invoice_number: 'SMCP-3', customer_id: 'cust-c' });

    await runInvoiceCron();

    expect(mockGenerateMonthlyInvoice).toHaveBeenCalledTimes(3);
    expect(mockRedisEval).toHaveBeenCalled();
  });
});
