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

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

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

// ============================================================================
// Mocks
// ============================================================================

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),
}));

// Queue mock — must mock before credit-consume is imported
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(),
    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: 'me_1',
    event_name: 'ai-meter-v2',
    status: 'pending',
    credit_consumed: '0',
    credit_pending: '0',
    attempt_count: 0,
    metadata: {},
    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 makeSubscription(overrides: Record<string, any> = {}) {
  return {
    id: 'sub_1',
    status: 'active',
    current_period_start: Math.floor(Date.now() / 1000),
    pending_invoice_item_interval: { interval: 'month' },
    isActive: jest.fn().mockReturnValue(true),
    update: jest.fn().mockResolvedValue(undefined),
    ...overrides,
  };
}

function setupBasicMocks(meterEvent: any, meter: any, customer: any, grants: any[], existingTx: any[] = []) {
  (MeterEvent.findByPk as jest.Mock).mockResolvedValue(meterEvent);
  (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: handleCreditConsumption', () => {
  const getLock = () => (global as any).__mockLock;

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

  // ==========================================
  // Scenario 1: Single event, sufficient balance
  // ==========================================
  it('scenario 1: consumes credit fully when grant has sufficient balance', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('100') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '1000', '500');

    setupBasicMocks(event, meter, customer, [grant]);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    // Should mark as processing
    expect(event.markAsProcessing).toHaveBeenCalledTimes(1);

    // Should consume from grant
    expect(grant.consumeCredit).toHaveBeenCalledWith('100', expect.objectContaining({ meter_event_id: 'me_1' }));

    // Should mark event as completed
    expect(event.update).toHaveBeenCalledWith(
      expect.objectContaining({
        status: 'completed',
        credit_consumed: '100',
        credit_pending: '0',
      })
    );

    // Should trigger auto-recharge outside lock
    expect(checkAndTriggerAutoRecharge).toHaveBeenCalled();

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

  // ==========================================
  // Scenario 2: Single event, insufficient balance
  // ==========================================
  it('scenario 2: handles insufficient balance correctly', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('500') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '200', '200');

    setupBasicMocks(event, meter, customer, [grant]);

    await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow('Insufficient credit balance');

    // Should create insufficient event
    expect(createEvent).toHaveBeenCalledWith(
      'Customer',
      'customer.credit.insufficient',
      customer,
      expect.objectContaining({
        metadata: expect.objectContaining({ meter_event_id: 'me_1' }),
      })
    );

    // Should save partial progress
    expect(event.update).toHaveBeenCalledWith(
      expect.objectContaining({
        credit_consumed: '200',
        credit_pending: '300',
      })
    );

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

  // ==========================================
  // Scenario 3: Exact balance → grant depleted
  // ==========================================
  it('scenario 3: fully consumes when balance exactly matches', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('200') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '200', '200');

    setupBasicMocks(event, meter, customer, [grant]);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    expect(grant.consumeCredit).toHaveBeenCalledWith('200', expect.any(Object));
    expect(event.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
    // Grant remaining should be 0 after consumeCredit mock
    expect(grant.remaining_amount).toBe('0');
  });

  // ==========================================
  // Scenario 4: No grants available
  // ==========================================
  it('scenario 4: handles no grants scenario', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('100') });
    const meter = makeMeter();
    const customer = makeCustomer();

    setupBasicMocks(event, meter, customer, []); // no grants

    await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow('Insufficient credit balance');

    expect(createEvent).toHaveBeenCalledWith('Customer', 'customer.credit.insufficient', customer, expect.any(Object));
  });

  // ==========================================
  // Scenario 5: Idempotency — already completed
  // ==========================================
  it('scenario 5: skips already completed events', async () => {
    const event = makeMeterEvent({ status: 'completed' });
    (MeterEvent.findByPk as jest.Mock).mockResolvedValue(event);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    // Should not acquire lock
    expect(getLock().acquire).not.toHaveBeenCalled();
  });

  // ==========================================
  // Scenario 13: With subscription — priceIds filtering
  // ==========================================
  it('scenario 13: loads subscription inside lock and uses priceIds', async () => {
    const event = makeMeterEvent({
      getSubscriptionId: jest.fn().mockReturnValue('sub_1'),
      getValue: jest.fn().mockReturnValue('100'),
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const subscription = makeSubscription();
    const grant = makeGrant('grant_1', '1000', '500');

    setupBasicMocks(event, meter, customer, [grant]);
    (Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    // Subscription should be loaded inside lock
    expect(Subscription.findByPk).toHaveBeenCalledWith('sub_1');
    // Grant query should use priceIds
    expect(CreditGrant.getAvailableCreditsForCustomer).toHaveBeenCalledWith('cus_1', 'cur_1', ['price_1']);
  });

  // ==========================================
  // Scenario 14: Subscription not found
  // ==========================================
  it('scenario 14: skips consumption when subscription not found', async () => {
    const event = makeMeterEvent({
      getSubscriptionId: jest.fn().mockReturnValue('sub_missing'),
      getValue: jest.fn().mockReturnValue('100'),
    });
    const meter = makeMeter();
    const customer = makeCustomer();

    setupBasicMocks(event, meter, customer, []);
    (Subscription.findByPk as jest.Mock).mockResolvedValue(null);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    // Should skip — no grant consumption attempted
    expect(CreditGrant.getAvailableCreditsForCustomer).not.toHaveBeenCalled();
    expect(getLock().release).toHaveBeenCalled();
  });

  // ==========================================
  // Scenario 17: Multi-grant cross consumption
  // ==========================================
  it('scenario 17: consumes across multiple grants', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('150') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant1 = makeGrant('grant_1', '100', '100');
    const grant2 = makeGrant('grant_2', '200', '200');

    setupBasicMocks(event, meter, customer, [grant1, grant2]);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    // Should consume 100 from grant1 and 50 from grant2
    expect(grant1.consumeCredit).toHaveBeenCalledWith('100', expect.any(Object));
    expect(grant2.consumeCredit).toHaveBeenCalledWith('50', expect.any(Object));

    // Should create 2 transactions
    expect(CreditTransaction.create).toHaveBeenCalledTimes(2);

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

  // ==========================================
  // Scenario 22/23: Retry scheduling + max retries
  // ==========================================
  it('scenario 22: schedules retry on partial failure', async () => {
    const event = makeMeterEvent({
      getValue: jest.fn().mockReturnValue('500'),
      attempt_count: 0,
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '100', '100');

    setupBasicMocks(event, meter, customer, [grant]);

    await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();

    // Should schedule retry (attempt_count=0 < MAX_RETRY_COUNT=3)
    expect(event.markAsRequiresCapture).toHaveBeenCalled();
  });

  it('scenario 23: marks as requires_action after max retries', async () => {
    const event = makeMeterEvent({
      getValue: jest.fn().mockReturnValue('500'),
      attempt_count: 3, // >= MAX_RETRY_COUNT
    });
    const meter = makeMeter();
    const customer = makeCustomer();

    setupBasicMocks(event, meter, customer, []);

    await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();

    expect(event.markAsRequiresAction).toHaveBeenCalled();
    expect(event.markAsRequiresCapture).not.toHaveBeenCalled();
  });

  // ==========================================
  // Scenario 26: Past due subscription recovery
  // ==========================================
  it('scenario 26: triggers past_due recovery on successful consumption', async () => {
    const subscription = makeSubscription({ status: 'past_due' });
    const event = makeMeterEvent({
      getSubscriptionId: jest.fn().mockReturnValue('sub_1'),
      getValue: jest.fn().mockReturnValue('50'),
    });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '1000', '500');

    setupBasicMocks(event, meter, customer, [grant]);
    (Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    expect(handlePastDueSubscriptionRecovery).toHaveBeenCalledWith(subscription, null);
  });

  // ==========================================
  // Scenario 30: Zero value event
  // ==========================================
  it('scenario 30: handles zero-value event', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('0') });
    const meter = makeMeter();
    const customer = makeCustomer();
    const grant = makeGrant('grant_1', '100', '100');

    setupBasicMocks(event, meter, customer, [grant]);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    // No grants should be consumed
    expect(grant.consumeCredit).not.toHaveBeenCalled();
    // Event should be completed
    expect(event.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
  });

  // ==========================================
  // Scenario 33: Low balance threshold
  // ==========================================
  it('scenario 33: triggers low_balance event when remaining < threshold', async () => {
    const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('95') });
    const meter = makeMeter();
    const customer = makeCustomer();
    // Grant of 100 with 100 remaining → after consuming 95, only 5 left (5% < 10% threshold)
    const grant = makeGrant('grant_1', '100', '100');

    setupBasicMocks(event, meter, customer, [grant]);

    await handleCreditConsumption({ meterEventId: 'me_1' });

    expect(createEvent).toHaveBeenCalledWith(
      'Customer',
      'customer.credit.low_balance',
      customer,
      expect.objectContaining({
        metadata: expect.objectContaining({ currency_id: 'cur_1' }),
      })
    );
  });

  // ==========================================
  // Lock safety: always released
  // ==========================================
  it('always releases lock even when unexpected error occurs', async () => {
    const event = makeMeterEvent();
    const meter = makeMeter();
    const customer = makeCustomer();

    (MeterEvent.findByPk as jest.Mock).mockResolvedValue(event);
    (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
    (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
    // Throw inside lock
    (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockRejectedValue(new Error('DB crash'));
    (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);

    await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow('DB crash');

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