import { BN } from '@ocap/util';

import { handleBatchCreditConsumption } from '../../src/queues/credit-consume';

import { MeterEvent, CreditGrant, CreditTransaction, Customer } from '../../src/store/models';
import { getCachedMeterExpanded } from '../../src/libs/reference-cache';
import { checkAndTriggerAutoRecharge } from '../../src/queues/auto-recharge';

// ============================================================================
// Mocks (same as credit-consume.spec.ts)
// ============================================================================

jest.mock('../../src/libs/logger', () => ({
  __esModule: true,
  default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
}));

jest.mock('../../src/libs/audit', () => ({
  createEvent: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../src/libs/event', () => ({
  events: { on: jest.fn() },
}));

jest.mock('../../src/libs/lock', () => {
  const lock = { acquire: jest.fn().mockResolvedValue(true), release: jest.fn(), locked: false };
  // @ts-ignore expose for test manipulation
  global.__mockLock = lock;
  return {
    getLock: jest.fn().mockReturnValue(lock),
    Lock: jest.fn(),
  };
});

jest.mock('../../src/libs/reference-cache', () => ({
  getCachedMeterExpanded: jest.fn(),
}));

jest.mock('../../src/libs/subscription', () => ({
  getDaysUntilCancel: jest.fn().mockReturnValue(7),
  getDueUnit: jest.fn().mockReturnValue(86400),
  getMeterPriceIdsFromSubscription: jest.fn().mockResolvedValue(['price_1']),
}));

jest.mock('../../src/libs/util', () => ({
  MAX_RETRY_COUNT: 3,
  getNextRetry: jest.fn().mockReturnValue(Math.floor(Date.now() / 1000) + 60),
  formatCreditAmount: jest.fn().mockImplementation((amount, symbol) => `${symbol}${amount}`),
}));

jest.mock('../../src/queues/payment', () => ({
  handlePastDueSubscriptionRecovery: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../src/queues/auto-recharge', () => ({
  checkAndTriggerAutoRecharge: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../src/queues/token-transfer', () => ({
  addTokenTransferJob: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../src/libs/queue', () => {
  const mockQueue = {
    push: jest.fn(),
    get: jest.fn().mockResolvedValue(null),
    delete: jest.fn().mockResolvedValue(undefined),
    on: jest.fn(),
  };
  return jest.fn().mockReturnValue(mockQueue);
});

jest.mock('../../src/store/models', () => ({
  MeterEvent: {
    findByPk: jest.fn(),
    findAll: jest.fn(),
    update: jest.fn().mockResolvedValue([0]),
    getPendingAmounts: jest.fn(),
  },
  CreditGrant: {
    getAvailableCreditsForCustomer: jest.fn(),
    hasOnchainToken: jest.fn().mockReturnValue(false),
  },
  CreditTransaction: {
    findAll: jest.fn(),
    findOne: jest.fn(),
    create: jest.fn(),
  },
  Customer: {
    findByPk: jest.fn(),
  },
  Subscription: {
    findByPk: jest.fn(),
  },
}));

// ============================================================================
// Helpers
// ============================================================================

function makeMeterEvent(overrides: Record<string, any> = {}) {
  return {
    id: overrides.id || 'me_1',
    event_name: 'ai-meter-v2',
    status: 'pending',
    credit_consumed: '0',
    credit_pending: '0',
    attempt_count: 0,
    metadata: {},
    created_at: overrides.created_at || new Date(),
    getCustomerId: jest.fn().mockReturnValue('cus_1'),
    getSubscriptionId: jest.fn().mockReturnValue(undefined),
    getValue: jest.fn().mockReturnValue('100'),
    markAsProcessing: jest.fn().mockResolvedValue(undefined),
    markAsRequiresAction: jest.fn().mockResolvedValue(undefined),
    markAsRequiresCapture: jest.fn().mockResolvedValue(undefined),
    update: jest.fn().mockResolvedValue(undefined),
    ...overrides,
  };
}

function makeMeter(overrides: Record<string, any> = {}) {
  return {
    id: 'meter_1',
    name: 'AI Meter',
    unit: 'token',
    currency_id: 'cur_1',
    paymentCurrency: { id: 'cur_1', symbol: '$', decimal: 2 },
    ...overrides,
  };
}

function makeCustomer(overrides: Record<string, any> = {}) {
  return { id: 'cus_1', did: 'did:abt:abc', ...overrides };
}

function makeGrant(id: string, amount: string, remaining: string, overrides: Record<string, any> = {}) {
  return {
    id,
    customer_id: 'cus_1',
    currency_id: 'cur_1',
    amount,
    remaining_amount: remaining,
    status: 'granted',
    metadata: {},
    consumeCredit: jest.fn().mockImplementation(function returnConsumeCredit(this: any, consumeAmt: string) {
      const newRemaining = new BN(this.remaining_amount).sub(new BN(consumeAmt));
      this.remaining_amount = newRemaining.toString();
      const depleted = newRemaining.lte(new BN(0));
      return Promise.resolve({ consumed: consumeAmt, remaining: this.remaining_amount, depleted });
    }),
    save: jest.fn().mockResolvedValue(undefined),
    ...overrides,
  };
}

function setupBatchMocks(events: any[], meter: any, customer: any, grants: any[], existingTx: any[] = []) {
  // findAll is called twice: pre-check and fresh read inside lock
  (MeterEvent.findAll as jest.Mock).mockResolvedValue(events);
  (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
  (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
  (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue(grants);
  (CreditTransaction.findAll as jest.Mock).mockResolvedValue(existingTx);
  (CreditTransaction.create as jest.Mock).mockImplementation((data: any) =>
    Promise.resolve({ id: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, ...data })
  );
}

// ============================================================================
// Tests
// ============================================================================

describe('credit-consume: handleBatchCreditConsumption', () => {
  const getLock = () => (global as any).__mockLock;

  beforeEach(() => {
    jest.clearAllMocks();
    getLock().acquire.mockResolvedValue(true);
    getLock().release.mockReset();
  });

  // ==========================================
  // Batch 1: Multiple events, sufficient balance
  // ==========================================
  it('consumes credits for multiple events when balance is sufficient', async () => {
    const event1 = makeMeterEvent({
      id: 'me_1',
      getValue: jest.fn().mockReturnValue('100'),
      created_at: new Date(1000),
    });
    const event2 = makeMeterEvent({
      id: 'me_2',
      getValue: jest.fn().mockReturnValue('50'),
      created_at: new Date(2000),
    });
    const event3 = makeMeterEvent({
      id: 'me_3',
      getValue: jest.fn().mockReturnValue('30'),
      created_at: new Date(3000),
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '1000', '500');

    setupBatchMocks([event1, event2, event3], meter, customer, [grant]);

    await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2', 'me_3'] });

    // All events should be completed
    expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
    expect(event2.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
    expect(event3.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));

    // Grant should be consumed: 100 + 50 + 30 = 180
    expect(grant.remaining_amount).toBe('320');

    // Lock released
    expect(getLock().release).toHaveBeenCalled();

    // Auto-recharge triggered
    expect(checkAndTriggerAutoRecharge).toHaveBeenCalled();
  });

  // ==========================================
  // Batch 2: Partial insufficient balance
  // ==========================================
  it('handles partial insufficient balance — some events succeed, later ones fail', async () => {
    const event1 = makeMeterEvent({
      id: 'me_1',
      getValue: jest.fn().mockReturnValue('80'),
      created_at: new Date(1000),
    });
    const event2 = makeMeterEvent({
      id: 'me_2',
      getValue: jest.fn().mockReturnValue('80'),
      created_at: new Date(2000),
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '100', '100');

    setupBatchMocks([event1, event2], meter, customer, [grant]);

    await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });

    // Event 1 should succeed (80 consumed, 20 remaining)
    expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));

    // Event 2 should fail — only 20 remaining but needs 80
    // It should have partial consumption saved and be marked for retry
    expect(event2.update).toHaveBeenCalledWith(
      expect.objectContaining({
        credit_pending: expect.any(String),
      })
    );
    expect(event2.markAsRequiresCapture).toHaveBeenCalled();

    expect(getLock().release).toHaveBeenCalled();
  });

  // ==========================================
  // Batch 3: FIFO ordering
  // ==========================================
  it('processes events in FIFO order by created_at', async () => {
    const callOrder: string[] = [];
    const event1 = makeMeterEvent({
      id: 'me_late',
      created_at: new Date(3000),
      getValue: jest.fn().mockImplementation(() => {
        callOrder.push('me_late');
        return '10';
      }),
    });
    const event2 = makeMeterEvent({
      id: 'me_early',
      created_at: new Date(1000),
      getValue: jest.fn().mockImplementation(() => {
        callOrder.push('me_early');
        return '10';
      }),
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '100', '100');

    setupBatchMocks([event1, event2], meter, customer, [grant]);

    await handleBatchCreditConsumption({ meterEventIds: ['me_late', 'me_early'] });

    // Early event should be processed first
    expect(callOrder[0]).toBe('me_early');
    expect(callOrder[1]).toBe('me_late');
  });

  // ==========================================
  // Batch 4: Fallback to single on batch-level error
  // ==========================================
  it('resets processing events to requires_capture and falls back to single on batch error', async () => {
    const event1 = makeMeterEvent({ id: 'me_1' });
    const event2 = makeMeterEvent({ id: 'me_2' });
    const meter = makeMeter();
    const customer = makeCustomer();

    // Pre-check findAll returns events, but grant query throws
    (MeterEvent.findAll as jest.Mock).mockResolvedValue([event1, event2]);
    (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
    (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
    (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockRejectedValue(new Error('DB crash'));
    (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);

    await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });

    // Should reset only 'processing' events to 'requires_capture' (second update call)
    const updateCalls = (MeterEvent.update as jest.Mock).mock.calls;
    const resetCall = updateCalls.find((c: any) => c[0]?.status === 'requires_capture');
    expect(resetCall).toBeTruthy();
    expect(resetCall[1].where.status).toBe('processing');

    // Lock must be released
    expect(getLock().release).toHaveBeenCalled();
  });

  // ==========================================
  // Batch 5: Single event delegates to handleCreditConsumption
  // ==========================================
  it('delegates to single handler when batch contains only one event', async () => {
    const event = makeMeterEvent({ id: 'me_1', getValue: jest.fn().mockReturnValue('100') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '1000', '500');

    // Single event path uses findByPk, not findAll
    (MeterEvent.findByPk as jest.Mock).mockResolvedValue(event);
    (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
    (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
    (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue([grant]);
    (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);
    (CreditTransaction.create as jest.Mock).mockResolvedValue({ id: 'tx_1' });

    await handleBatchCreditConsumption({ meterEventIds: ['me_1'] });

    // Should use single-event path (findByPk, markAsProcessing)
    expect(MeterEvent.findByPk).toHaveBeenCalledWith('me_1');
    expect(event.markAsProcessing).toHaveBeenCalled();
  });

  // ==========================================
  // Batch 6: Already completed events are skipped
  // ==========================================
  it('skips already completed events in batch', async () => {
    const event1 = makeMeterEvent({ id: 'me_1', status: 'completed' });
    const event2 = makeMeterEvent({ id: 'me_2', getValue: jest.fn().mockReturnValue('50') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '1000', '500');

    // Pre-check returns both, but event1 is completed
    (MeterEvent.findAll as jest.Mock)
      .mockResolvedValueOnce([event1, event2]) // pre-check
      .mockResolvedValueOnce([event2]); // fresh read (only pending)
    (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
    (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
    (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue([grant]);
    (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);
    (CreditTransaction.create as jest.Mock).mockResolvedValue({ id: 'tx_1' });

    await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });

    // Only event2 should be processed
    expect(event1.update).not.toHaveBeenCalled();
    expect(event2.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
  });

  // ==========================================
  // Batch 7: Empty batch is no-op
  // ==========================================
  it('handles empty batch gracefully', async () => {
    await handleBatchCreditConsumption({ meterEventIds: [] });

    // Should not acquire lock or query anything
    expect(getLock().acquire).not.toHaveBeenCalled();
    expect(MeterEvent.findAll).not.toHaveBeenCalled();
  });

  // ==========================================
  // Batch 8: batchKey triggers onBatchComplete
  // ==========================================
  it('calls onBatchComplete when batchKey is provided', async () => {
    const event1 = makeMeterEvent({ id: 'me_1', getValue: jest.fn().mockReturnValue('10') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '1000', '500');

    setupBatchMocks([event1], meter, customer, [grant]);

    // With batchKey — should not throw even though onBatchComplete is internal
    await handleBatchCreditConsumption({
      meterEventIds: ['me_1'],
      batchKey: 'cus_1::ai-meter-v2::',
    });

    // Single event delegates to handleCreditConsumption, batchKey cleanup still happens
    expect(MeterEvent.findByPk).toHaveBeenCalled();
  });

  // ==========================================
  // Batch 9: Multi-grant cross consumption in batch
  // ==========================================
  it('consumes across multiple grants for batch events', async () => {
    const event1 = makeMeterEvent({
      id: 'me_1',
      getValue: jest.fn().mockReturnValue('80'),
      created_at: new Date(1000),
    });
    const event2 = makeMeterEvent({
      id: 'me_2',
      getValue: jest.fn().mockReturnValue('50'),
      created_at: new Date(2000),
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant1 = makeGrant('grant_1', '100', '100');
    const grant2 = makeGrant('grant_2', '200', '200');

    setupBatchMocks([event1, event2], meter, customer, [grant1, grant2]);

    await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });

    // Event1 consumes 80 from grant1 (remaining: 20)
    // Event2 consumes 20 from grant1 + 30 from grant2
    expect(grant1.remaining_amount).toBe('0');
    expect(grant2.remaining_amount).toBe('170');

    expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
    expect(event2.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
  });
});
