import { BN, fromUnitToToken } from '@ocap/util';
import { Op } from 'sequelize';
import pAll from 'p-all';

import { getLock } from '../libs/lock';
import logger from '../libs/logger';
import createQueue from '../libs/queue';
import { createEvent } from '../libs/audit';
import { MeterEvent, CreditGrant, CreditTransaction, Customer, Subscription, TMeterExpanded } from '../store/models';
import { getCachedMeterExpanded } from '../libs/reference-cache';

import { MAX_RETRY_COUNT, getNextRetry, formatCreditAmount } from '../libs/util';
import { getDaysUntilCancel, getDueUnit, getMeterPriceIdsFromSubscription } from '../libs/subscription';
import { events } from '../libs/event';
import { handlePastDueSubscriptionRecovery } from './payment';
import { checkAndTriggerAutoRecharge } from './auto-recharge';
import { addTokenTransferJob } from './token-transfer';

type CreditConsumptionJob = {
  meterEventId: string;
};

type BatchCreditConsumptionJob = {
  meterEventIds: string[];
  batchKey?: string;
};

type CreditConsumptionContext = {
  meterEvent: MeterEvent;
  meter: TMeterExpanded;
  customer: Customer;
  subscription?: Subscription;
  priceIds?: string[];
  transactions: string[];
  warnings: string[];
  _subscriptionId?: string;
};

type CreditConsumptionResult = {
  consumed: string;
  pending: string;
  available_balance: string;
  fully_consumed: boolean;
};

async function checkLowBalance(
  customerId: string,
  currencyId: string,
  totalCreditAmount: string,
  remainingBalance: string,
  context: CreditConsumptionContext
): Promise<void> {
  try {
    const totalCreditAmountBn = new BN(totalCreditAmount);
    if (totalCreditAmountBn.lte(new BN(0))) return;
    const remainingAmountBn = new BN(remainingBalance);
    // Get threshold percentage from env var, default to 10%
    const thresholdPercentage = parseInt(process.env.CREDIT_LOW_BALANCE_THRESHOLD_PERCENTAGE || '10', 10);
    const threshold = totalCreditAmountBn.mul(new BN(thresholdPercentage)).div(new BN(100));
    if (remainingAmountBn.gt(new BN(0)) && remainingAmountBn.lte(threshold)) {
      const percentage = remainingAmountBn.mul(new BN(100)).div(totalCreditAmountBn).toString();
      await createEvent('Customer', 'customer.credit.low_balance', context.customer, {
        metadata: {
          currency_id: currencyId,
          available_amount: remainingAmountBn.toString(),
          total_amount: totalCreditAmountBn.toString(),
          percentage,
          subscription_id: context.subscription?.id,
        },
      }).catch(console.error);
    }
  } catch (error: any) {
    logger.error('Failed to check low balance', {
      customerId,
      currencyId,
      error: error.message,
    });
  }
}
async function loadReferenceData(
  meterEventId: string,
  meterEvent: MeterEvent
): Promise<CreditConsumptionContext | null> {
  const customerId = meterEvent.getCustomerId();
  if (!customerId) {
    logger.warn('Skipping credit consumption job: MeterEvent missing customer_id', { meterEventId });
    return null;
  }

  const subscriptionId = meterEvent.getSubscriptionId();

  const [meter, customer] = await Promise.all([
    getCachedMeterExpanded(meterEvent.event_name) as Promise<TMeterExpanded | null>,
    Customer.findByPk(customerId),
  ]);

  if (!meter) {
    logger.warn('Skipping credit consumption job: Meter not found', {
      meterEventId,
      eventName: meterEvent.event_name,
    });
    return null;
  }

  if (!meter.currency_id) {
    logger.warn('Skipping credit consumption job: Meter missing currency_id', {
      meterEventId,
      meterId: meter.id,
    });
    return null;
  }

  if (!customer) {
    logger.warn('Skipping credit consumption job: Customer not found', {
      meterEventId,
      customerId,
    });
    return null;
  }

  return {
    meterEvent,
    meter,
    customer,
    transactions: [],
    warnings: [],
    _subscriptionId: subscriptionId,
  };
}

async function consumeAvailableCredits(
  context: CreditConsumptionContext,
  totalRequiredAmount: string
): Promise<CreditConsumptionResult> {
  const customerId = context.meterEvent.getCustomerId();
  const currencyId = context.meter.currency_id!;
  const meterEventId = context.meterEvent.id;

  const existingTransactions = await CreditTransaction.findAll({
    where: {
      source: meterEventId,
    },
  });

  const existingTxByGrantId = new Map(existingTransactions.map((tx) => [tx.credit_grant_id, tx]));

  const alreadyConsumed = existingTransactions.reduce((sum, tx) => sum.add(new BN(tx.credit_amount)), new BN(0));
  const remainingToConsume = new BN(totalRequiredAmount).sub(alreadyConsumed);

  // 如果已经完全消费，直接返回结果
  if (remainingToConsume.lte(new BN(0))) {
    logger.info('Credit consumption already completed', {
      meterEventId,
      totalRequiredAmount,
      alreadyConsumed: alreadyConsumed.toString(),
    });

    context.transactions.push(...existingTransactions.map((tx) => tx.id));

    return {
      consumed: alreadyConsumed.toString(),
      pending: '0',
      available_balance: '0',
      fully_consumed: true,
    };
  }

  const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, context.priceIds);

  const totalCreditAmountBN = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));

  // Calculate total available balance
  const totalAvailable = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0));
  logger.debug('Total available credits calculated', { totalAvailable: totalAvailable.toString() });

  let remaining = remainingToConsume;
  let consumed = new BN(0);

  // Consume credits from grants in priority order
  for (const grant of availableGrants) {
    if (remaining.lte(new BN(0))) break;

    const availableInGrant = new BN(grant.remaining_amount);

    // if remaining is less than availableInGrant, consume remaining
    // otherwise, consume availableInGrant
    const consumeAmount = remaining.lte(availableInGrant) ? remaining : availableInGrant;

    // if consumeAmount is greater than 0, process grant consumption
    if (consumeAmount.gt(new BN(0))) {
      await processGrantConsumption(context, grant, consumeAmount.toString(), existingTxByGrantId); // eslint-disable-line no-await-in-loop
      consumed = consumed.add(consumeAmount);
      remaining = remaining.sub(consumeAmount);
    }
  }

  const totalConsumed = alreadyConsumed.add(consumed);
  const finalPending = new BN(totalRequiredAmount).sub(totalConsumed);

  logger.info('Consumed credits', {
    customerId,
    currencyId,
    totalRequiredAmount,
    alreadyConsumed: alreadyConsumed.toString(),
    newlyConsumed: consumed.toString(),
    totalConsumed: totalConsumed.toString(),
    finalPending: finalPending.toString(),
    grantCount: availableGrants.length,
  });

  const pendingAmount = finalPending.lte(new BN(0)) ? '0' : finalPending.toString();
  const remainingBalance = totalAvailable.sub(consumed).toString();

  // Handle insufficient/low balance events
  await handlePostConsumptionEvents(context, {
    consumed: consumed.toString(),
    remainingToConsume,
    totalAvailable,
    pendingAmount,
    remainingBalance,
    totalCreditAmountBN,
    availableGrants,
  });

  return {
    consumed: totalConsumed.toString(),
    pending: pendingAmount,
    available_balance: remainingBalance,
    fully_consumed: finalPending.lte(new BN(0)),
  };
}

async function handlePostConsumptionEvents(
  context: CreditConsumptionContext,
  params: {
    consumed: string;
    remainingToConsume: BN;
    totalAvailable: BN;
    pendingAmount: string;
    remainingBalance: string;
    totalCreditAmountBN: BN;
    availableGrants: CreditGrant[];
  }
): Promise<void> {
  const customerId = context.meterEvent.getCustomerId();
  const currencyId = context.meter.currency_id!;
  const { consumed, remainingToConsume, totalAvailable, pendingAmount, remainingBalance, totalCreditAmountBN } = params;
  const finalPending = new BN(pendingAmount);

  let insufficientTriggered = false;

  // 如果无法完全消费所需额度，记录不足事件
  if (finalPending.gt(new BN(0))) {
    logger.warn('Insufficient credit balance', {
      customerId,
      currencyId,
      requiredAmount: remainingToConsume.toString(),
      availableAmount: totalAvailable.toString(),
      consumedAmount: consumed,
      pendingAmount,
    });
    if ((context.subscription && context.subscription.isActive()) || !context.subscription) {
      insufficientTriggered = true;
      await createEvent('Customer', 'customer.credit.insufficient', context.customer, {
        metadata: {
          meter_event_id: context.meterEvent.id,
          meter_name: context.meter.name,
          meter_event_name: context.meterEvent.event_name,
          required_amount: remainingToConsume.toString(),
          available_amount: totalAvailable.toString(),
          consumed_amount: consumed,
          pending_amount: pendingAmount,
          currency_id: currencyId,
          subscription_id: context.subscription?.id,
        },
      }).catch(console.error);
    }

    // 如果有关联订阅且订阅活跃，将其标记为逾期
    if (context.subscription && context.subscription.isActive()) {
      const cancelUpdates: { [key: string]: any } = {};
      const { interval } = context.subscription.pending_invoice_item_interval;
      const dueUnit = getDueUnit(interval);
      const daysUntilCancel = getDaysUntilCancel(context.subscription);
      if (daysUntilCancel > 0) {
        cancelUpdates.cancel_at = context.subscription.current_period_start + daysUntilCancel * dueUnit;
      } else {
        cancelUpdates.cancel_at_period_end = true;
      }
      await context.subscription.update({
        status: 'past_due',
        ...cancelUpdates,
        cancelation_details: {
          comment: 'past_due',
          feedback: 'other',
          reason: 'insufficient_credit',
        },
      });
    }
  } else if (new BN(remainingBalance).lte(new BN(0))) {
    insufficientTriggered = true;
    await createEvent('Customer', 'customer.credit.insufficient', context.customer, {
      metadata: {
        meter_event_id: context.meterEvent.id,
        meter_name: context.meter.name,
        meter_event_name: context.meterEvent.event_name,
        required_amount: remainingToConsume.toString(),
        available_amount: '0',
        consumed_amount: consumed,
        pending_amount: pendingAmount,
        currency_id: currencyId,
        subscription_id: context.subscription?.id,
      },
    }).catch(console.error);
  }

  if (!insufficientTriggered) {
    await checkLowBalance(customerId, currencyId, totalCreditAmountBN.toString(), remainingBalance, context);
  }
}

async function processGrantConsumption(
  context: CreditConsumptionContext,
  creditGrant: CreditGrant,
  consumeAmount: string,
  existingTxByGrantId?: Map<string, CreditTransaction>
): Promise<void> {
  logger.info('Processing grant consumption', {
    creditGrantId: creditGrant.id,
    consumeAmount,
  });

  const result = await creditGrant.consumeCredit(consumeAmount, {
    subscription_id: context.subscription?.id,
    meter_event_id: context.meterEvent.id,
  });

  const transactionId = await createCreditTransaction(context, creditGrant, consumeAmount, result, existingTxByGrantId);

  if (CreditGrant.hasOnchainToken(creditGrant)) {
    addTokenTransferJob({
      creditTransactionId: transactionId,
      creditGrantId: creditGrant.id,
      customerDid: context.customer.did,
      amount: consumeAmount,
      paymentCurrencyId: context.meter.paymentCurrency.id,
      meterEventId: context.meterEvent.id,
      subscriptionId: context.meterEvent.getSubscriptionId(),
    }).catch((error) => {
      logger.error('Failed to add token transfer job', {
        creditTransactionId: transactionId,
        error,
      });
    });
  }
}

async function createCreditTransaction(
  context: CreditConsumptionContext,
  creditGrant: CreditGrant,
  consumeAmount: string,
  consumeResult: any,
  existingTxByGrantId?: Map<string, CreditTransaction>
): Promise<string> {
  const meterEventId = context.meterEvent.id;
  const creditGrantId = creditGrant.id;

  const existingTransaction = existingTxByGrantId?.get(creditGrantId) ?? null;

  if (existingTransaction) {
    logger.warn('CreditTransaction already exists for this MeterEvent and CreditGrant', {
      meterEventId,
      creditGrantId,
      existingTransactionId: existingTransaction.id,
    });
    context.transactions.push(existingTransaction.id);
    return existingTransaction.id;
  }

  logger.debug('start to create credit transaction', {
    customerId: context.meterEvent.getCustomerId(),
    creditGrantId: creditGrant.id,
    meterId: context.meter.id,
    subscriptionId: context.meterEvent.getSubscriptionId(),
    meterEventId: context.meterEvent.id,
  });

  const formattedAmount = formatCreditAmount(
    fromUnitToToken(consumeAmount, context.meter.paymentCurrency.decimal),
    context.meter.paymentCurrency.symbol
  );

  let description = `Consume ${formattedAmount}`;
  const resolvedSubscriptionId =
    context.meterEvent.getSubscriptionId() || creditGrant.metadata?.subscription_id || context.subscription?.id;

  if (resolvedSubscriptionId) {
    description += ' for Subscription';
  }

  if (context.meterEvent.metadata?.description) {
    description = context.meterEvent.metadata.description;
  }

  const hasPendingBalance = new BN(context.meterEvent.credit_pending || '0').gt(new BN(0));
  const hasFailureHistory = !!(context.meterEvent.metadata?.last_error || context.meterEvent.metadata?.failed_at);
  const isRepayment = hasPendingBalance && hasFailureHistory;
  if (isRepayment) {
    description = `Repay ${formattedAmount}`;
  }

  try {
    const transaction = await CreditTransaction.create({
      customer_id: context.meterEvent.getCustomerId(),
      credit_grant_id: creditGrant.id,
      meter_id: context.meter.id,
      subscription_id: resolvedSubscriptionId,
      meter_event_name: context.meterEvent.event_name,
      meter_unit: context.meter.unit,
      quantity: consumeAmount,
      credit_amount: consumeResult.consumed,
      remaining_balance: consumeResult.remaining,
      source: context.meterEvent.id,
      description,
      // 如果是链上 grant，会在后续 transfer 成功之后更新为 completed
      transfer_status: CreditGrant.hasOnchainToken(creditGrant) ? 'pending' : 'completed',
      metadata: {
        ...(context.meterEvent.metadata || {}),
        meter_event_id: context.meterEvent.id,
        grant_depleted: consumeResult.depleted,
        is_repayment: isRepayment,
      },
    });

    logger.info('Created credit transaction', {
      transactionId: transaction.id,
      creditGrantId: creditGrant.id,
      meterId: context.meter.id,
      subscriptionId: context.meterEvent.getSubscriptionId(),
      meterEventId: context.meterEvent.id,
    });
    context.transactions.push(transaction.id);

    return transaction.id;
  } catch (error: any) {
    // 处理唯一约束违反错误
    if (error.name === 'SequelizeUniqueConstraintError' || error.message.includes('UNIQUE constraint failed')) {
      logger.warn('CreditTransaction creation failed due to unique constraint - transaction already exists', {
        meterEventId,
        creditGrantId,
        error: error.message,
      });

      // 重新查询已存在的 transaction
      const duplicateTransaction = await CreditTransaction.findOne({
        where: {
          source: meterEventId,
          credit_grant_id: creditGrantId,
        },
      });

      if (duplicateTransaction) {
        context.transactions.push(duplicateTransaction.id);
        return duplicateTransaction.id;
      }
    }

    // 其他错误重新抛出
    throw error;
  }
}

// ============================================================================
// Single-event handler
// ============================================================================

export async function handleCreditConsumption(job: CreditConsumptionJob) {
  const { meterEventId } = job;
  const jobStart = performance.now();

  logger.info('Starting credit consumption job', { meterEventId });

  // Pre-check before acquiring lock
  const preCheckEvent = await MeterEvent.findByPk(meterEventId);
  if (!preCheckEvent) {
    logger.warn('Skipping credit consumption job: MeterEvent not found', { meterEventId });
    return;
  }
  if (preCheckEvent.status === 'completed' || preCheckEvent.status === 'canceled') {
    logger.info('Skipping credit consumption job: MeterEvent already processed', {
      meterEventId,
      status: preCheckEvent.status,
    });
    return;
  }
  if (preCheckEvent.status === 'requires_action') {
    logger.warn('Skipping credit consumption job: MeterEvent requires manual intervention', { meterEventId });
    return;
  }

  // Load reference data
  const validateStart = performance.now();
  const context: CreditConsumptionContext | null = await loadReferenceData(meterEventId, preCheckEvent);
  const validateTime = performance.now() - validateStart;

  if (!context) {
    logger.warn('Credit consumption context not found, skipping job', { meterEventId });
    return;
  }

  const customerId = context.customer.id;
  const lock = getLock(`credit-consumption-customer-${customerId}`);

  let rechargeParams: {
    customer: Customer;
    currencyId: string;
    availableBalance: string;
    currency: any;
    meter: TMeterExpanded;
  } | null = null;
  let rechargeTime = 0;

  try {
    const lockStart = performance.now();
    await lock.acquire();
    const lockTime = performance.now() - lockStart;

    // Fresh loads inside lock for consistency
    const [freshEvent, freshSubscription] = await Promise.all([
      MeterEvent.findByPk(meterEventId),
      context._subscriptionId ? Subscription.findByPk(context._subscriptionId) : Promise.resolve(null),
    ]);
    if (!freshEvent) {
      logger.warn('MeterEvent disappeared after lock acquired', { meterEventId });
      return;
    }
    context.meterEvent = freshEvent;

    if (context._subscriptionId && !freshSubscription) {
      logger.warn('Skipping credit consumption: Subscription not found inside lock', {
        meterEventId,
        subscriptionId: context._subscriptionId,
      });
      return;
    }
    if (freshSubscription) {
      context.subscription = freshSubscription;
      context.priceIds = await getMeterPriceIdsFromSubscription(freshSubscription);
    }

    if (freshEvent.status === 'completed' || freshEvent.status === 'canceled') {
      logger.info('MeterEvent already processed, skipping', {
        meterEventId,
        status: freshEvent.status,
      });
      return;
    }

    if (freshEvent.status === 'processing') {
      logger.warn('MeterEvent is already being processed, skipping duplicate job', {
        meterEventId,
        status: freshEvent.status,
      });
      return;
    }

    // Mark as processing to prevent duplicate processing
    await freshEvent.markAsProcessing();
    const totalRequiredAmount = freshEvent.getValue();

    logger.info('Attempting to consume credits', {
      meterEventId,
      customerId,
      totalRequiredAmount,
      currencyId: context.meter.currency_id,
    });

    const consumeStart = performance.now();
    const consumptionResult = await consumeAvailableCredits(context, totalRequiredAmount);
    const consumeTime = performance.now() - consumeStart;

    if (consumptionResult.fully_consumed) {
      await freshEvent.update({
        status: 'completed',
        processed_at: Math.floor(Date.now() / 1000),
        next_attempt: undefined,
        credit_consumed: consumptionResult.consumed,
        credit_pending: consumptionResult.pending,
        metadata: {
          ...freshEvent.metadata,
          consumption_result: consumptionResult,
          last_error: undefined,
          failed_at: undefined,
        },
      });

      rechargeParams = {
        customer: context.customer,
        currencyId: context.meter.currency_id!,
        availableBalance: consumptionResult.available_balance,
        currency: context.meter.paymentCurrency,
        meter: context.meter,
      };

      const totalTime = performance.now() - jobStart;
      logger.info('Credit consumption completed successfully', {
        meterEventId,
        customerId,
        totalRequired: totalRequiredAmount,
        consumed: consumptionResult.consumed,
        pending: consumptionResult.pending,
        transactionCount: context.transactions.length,
        timing: {
          validateMs: Math.round(validateTime),
          lockMs: Math.round(lockTime),
          consumeMs: Math.round(consumeTime),
          totalMs: Math.round(totalTime),
        },
      });

      if (context.subscription && context.subscription.status === 'past_due') {
        await handlePastDueSubscriptionRecovery(context.subscription, null);
      }
    } else {
      // Save partial consumption progress before throwing
      await freshEvent.update({
        credit_consumed: consumptionResult.consumed,
        credit_pending: consumptionResult.pending,
        metadata: {
          ...freshEvent.metadata,
          consumption_result: consumptionResult,
          processed_at: new Date().toISOString(),
        },
      });

      logger.warn('Credit consumption partially completed - insufficient balance', {
        meterEventId,
        customerId,
        totalRequired: totalRequiredAmount,
        finalConsumed: consumptionResult.consumed,
        finalPending: consumptionResult.pending,
        availableBalance: consumptionResult.available_balance,
      });

      // Trigger auto-recharge even for partial consumption
      rechargeParams = {
        customer: context.customer,
        currencyId: context.meter.currency_id!,
        availableBalance: consumptionResult.available_balance,
        currency: context.meter.paymentCurrency,
        meter: context.meter,
      };

      throw new Error(
        `Insufficient credit balance: required ${totalRequiredAmount}, consumed ${consumptionResult.consumed}, pending ${consumptionResult.pending}`
      );
    }
  } catch (error: any) {
    logger.error('Credit consumption failed', {
      meterEventId,
      customerId,
      error: error.message,
    });

    // Handle retry logic with more robust error handling
    try {
      const meterEvent = await MeterEvent.findByPk(meterEventId);
      if (meterEvent && !['completed', 'canceled', 'requires_action'].includes(meterEvent.status)) {
        const attemptCount = meterEvent.attempt_count + 1;

        if (attemptCount >= MAX_RETRY_COUNT) {
          await meterEvent.markAsRequiresAction(error.message);
          logger.warn('MeterEvent marked as requires_action', {
            meterEventId,
            attemptCount,
            reason: attemptCount >= MAX_RETRY_COUNT ? 'max_retries_exceeded' : 'non_retryable_error',
          });
        } else {
          const nextAttemptTime = getNextRetry(attemptCount);
          await meterEvent.markAsRequiresCapture(error.message, nextAttemptTime);

          addCreditConsumptionJob(meterEventId, true, { runAt: nextAttemptTime, skipStatusCheck: true });

          logger.warn('Credit consumption retry scheduled', {
            meterEventId,
            attemptCount,
            nextAttemptTime,
            error: error.message,
          });
        }
      }
    } catch (updateError: any) {
      logger.error('Failed to update meter event failure status', {
        meterEventId,
        originalError: error.message,
        updateError: updateError.message,
      });
    }

    // 重新抛出错误，让队列知道任务失败
    throw error;
  } finally {
    lock.release();

    // Auto-recharge check outside lock to reduce lock hold time
    // Runs for both successful and partial consumption (insufficient balance)
    if (rechargeParams) {
      const rechargeStart = performance.now();
      try {
        await checkAndTriggerAutoRecharge(
          rechargeParams.customer,
          rechargeParams.currencyId,
          rechargeParams.availableBalance,
          {
            currency: rechargeParams.currency,
            meter: rechargeParams.meter,
          }
        );
      } catch (error: any) {
        logger.warn('Auto recharge check failed after credit consumption', {
          meterEventId,
          customerId,
          error: error.message,
        });
      }
      rechargeTime = performance.now() - rechargeStart;
      if (rechargeTime > 50) {
        logger.info('Auto recharge check timing', { meterEventId, rechargeMs: Math.round(rechargeTime) });
      }
    }
  }
}

// ============================================================================
// Batch credit consumption
// ============================================================================
const CREDIT_BATCH_SIZE = Math.max(1, parseInt(process.env.CREDIT_BATCH_SIZE || '50', 10));
const CREDIT_BATCH_WINDOW_MS = Math.max(10, parseInt(process.env.CREDIT_BATCH_WINDOW_MS || '3000', 10));

type PendingBatch = { eventIds: string[]; timer: NodeJS.Timeout | null };
const pendingBatches = new Map<string, PendingBatch>();
// Track in-flight batch jobs per key to avoid head-of-line blocking.
// Only one batch job per key runs at a time; new events accumulate until it finishes.
const inflightBatches = new Set<string>();

function getBatchKey(customerId: string, eventName: string, subscriptionId?: string): string {
  return `${customerId}::${eventName}::${subscriptionId || ''}`;
}

function addToBatch(customerId: string, eventName: string, meterEventId: string, subscriptionId?: string) {
  const key = getBatchKey(customerId, eventName, subscriptionId);
  let batch = pendingBatches.get(key);
  if (!batch) {
    batch = { eventIds: [], timer: null };
    pendingBatches.set(key, batch);
  }

  batch.eventIds.push(meterEventId);

  if (batch.eventIds.length >= CREDIT_BATCH_SIZE) {
    flushBatch(key);
    return;
  }

  if (!batch.timer) {
    batch.timer = setTimeout(() => flushBatch(key), CREDIT_BATCH_WINDOW_MS);
  }
}

function flushBatch(key: string) {
  const batch = pendingBatches.get(key);
  if (!batch || batch.eventIds.length === 0) {
    pendingBatches.delete(key);
    return;
  }

  // If a batch for this key is already processing, keep events pending.
  // They will be flushed when the in-flight batch completes (see onBatchComplete).
  if (inflightBatches.has(key)) {
    return;
  }

  if (batch.timer) {
    clearTimeout(batch.timer);
  }
  const eventIds = batch.eventIds.splice(0);
  pendingBatches.delete(key);

  inflightBatches.add(key);
  const jobId = `batch-credit-${key}-${Date.now()}`;
  creditQueue.push({
    id: jobId,
    job: { meterEventIds: eventIds, batchKey: key } as any,
    skipDuplicateCheck: true,
  });
}

function onBatchComplete(key: string) {
  inflightBatches.delete(key);
  // Flush any events that accumulated while this batch was processing
  if (pendingBatches.has(key)) {
    flushBatch(key);
  }
}

// Graceful shutdown: flush pending batches. If push doesn't persist before exit,
// events remain 'pending' and are recovered by startCreditConsumeQueue on restart.
function flushAllBatches() {
  const keys = Array.from(pendingBatches.keys());
  keys.forEach((key) => flushBatch(key));
}
process.on('SIGTERM', flushAllBatches);
process.on('SIGINT', flushAllBatches);

export async function handleBatchCreditConsumption(job: BatchCreditConsumptionJob) {
  const { meterEventIds, batchKey } = job;
  const batchStart = performance.now();

  try {
    await handleBatchCreditConsumptionInner(meterEventIds, batchStart);
  } finally {
    if (batchKey) {
      onBatchComplete(batchKey);
    }
  }
}

async function handleBatchCreditConsumptionInner(meterEventIds: string[], batchStart: number) {
  if (!meterEventIds || meterEventIds.length === 0) return;

  if (meterEventIds.length === 1) {
    await handleCreditConsumption({ meterEventId: meterEventIds[0]! });
    return;
  }

  // ==========================================
  // Pre-check: filter already-processed events
  // ==========================================
  const preCheckEvents = await MeterEvent.findAll({
    where: { id: { [Op.in]: meterEventIds } },
  });

  const processableEvents = preCheckEvents.filter(
    (e) => !['completed', 'canceled', 'requires_action'].includes(e.status)
  );
  if (processableEvents.length === 0) return;

  const customerId = processableEvents[0]!.getCustomerId();
  const eventName = processableEvents[0]!.event_name;

  const mismatchedEvents = processableEvents.filter((e) => e.event_name !== eventName);
  if (mismatchedEvents.length > 0) {
    logger.error('Batch contains mixed event_names, falling back to single processing', {
      customerId,
      expectedEventName: eventName,
      mismatchedCount: mismatchedEvents.length,
    });
    for (const event of processableEvents) {
      addCreditConsumptionJob(event.id, true, { skipStatusCheck: true });
    }
    return;
  }

  // ==========================================
  // Load reference data
  // ==========================================
  const [meter, customer] = await Promise.all([
    getCachedMeterExpanded(eventName) as Promise<TMeterExpanded | null>,
    Customer.findByPk(customerId),
  ]);

  if (!meter || !meter.currency_id || !customer) {
    logger.warn('Batch credit consumption: reference data not found', {
      batchSize: meterEventIds.length,
      customerId,
    });
    return;
  }

  const currencyId = meter.currency_id;
  const lock = getLock(`credit-consumption-customer-${customerId}`);

  let rechargeParams: {
    customer: Customer;
    currencyId: string;
    availableBalance: string;
    currency: any;
    meter: TMeterExpanded;
  } | null = null;

  try {
    const lockStart = performance.now();
    await lock.acquire();
    const lockTime = performance.now() - lockStart;

    // ==========================================
    // Fresh read inside lock
    // ==========================================
    const processableIds = processableEvents.map((e) => e.id);
    const freshEvents = await MeterEvent.findAll({
      where: { id: { [Op.in]: processableIds } },
    });

    const freshProcessable = freshEvents.filter(
      (e) => !['completed', 'canceled', 'processing', 'requires_action'].includes(e.status)
    );
    if (freshProcessable.length === 0) return;

    // Subscription fresh read (once for entire batch)
    const subscriptionId = freshProcessable[0]!.getSubscriptionId();
    let subscription: Subscription | null = null;
    let priceIds: string[] | undefined;

    if (subscriptionId) {
      subscription = await Subscription.findByPk(subscriptionId);
      if (!subscription) {
        logger.warn('Batch: Subscription not found inside lock, skipping', {
          customerId,
          subscriptionId,
        });
        return;
      }
      priceIds = await getMeterPriceIdsFromSubscription(subscription);
    }

    // Mark all processable events as 'processing' in one SQL
    await MeterEvent.update(
      { status: 'processing' },
      { where: { id: { [Op.in]: freshProcessable.map((e) => e.id) } } }
    );
    // Sync in-memory status for subsequent logic
    freshProcessable.forEach((e) => {
      e.status = 'processing';
    });

    // ==========================================
    // Batch idempotency check
    // ==========================================
    const allEventIds = freshProcessable.map((e) => e.id);
    const existingTransactions = await CreditTransaction.findAll({
      where: { source: { [Op.in]: allEventIds } },
    });
    const txBySource = new Map<string, CreditTransaction[]>();
    for (const tx of existingTransactions) {
      const source = tx.source!;
      const arr = txBySource.get(source) || [];
      arr.push(tx);
      txBySource.set(source, arr);
    }

    // ==========================================
    // Query available grants once for entire batch
    // ==========================================
    const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, priceIds);

    const totalCreditAmountBN = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));

    // ==========================================
    // Consume credits per event — MUST be sequential because events share
    // the same grant objects and consumeCredit() mutates remaining_amount.
    // ==========================================
    /* eslint-disable no-await-in-loop */
    const successEvents: Array<MeterEvent & { _consumptionResult?: CreditConsumptionResult }> = [];
    const failedEvents: { event: MeterEvent; error: Error }[] = [];

    // FIFO order by creation time
    freshProcessable.sort((a, b) => {
      const aTime = a.created_at instanceof Date ? a.created_at.getTime() : Number(a.created_at);
      const bTime = b.created_at instanceof Date ? b.created_at.getTime() : Number(b.created_at);
      return aTime - bTime;
    });

    for (const freshEvent of freshProcessable) {
      try {
        const context: CreditConsumptionContext = {
          meterEvent: freshEvent,
          meter,
          customer,
          subscription: subscription || undefined,
          priceIds,
          transactions: [],
          warnings: [],
        };

        const totalRequiredAmount = freshEvent.getValue();

        // Use pre-fetched existingTx data (from batch query)
        const eventExistingTx = txBySource.get(freshEvent.id) || [];
        const existingTxByGrantId = new Map(eventExistingTx.map((tx) => [tx.credit_grant_id, tx]));
        const alreadyConsumed = eventExistingTx.reduce((sum, tx) => sum.add(new BN(tx.credit_amount)), new BN(0));
        const remainingToConsume = new BN(totalRequiredAmount).sub(alreadyConsumed);

        if (remainingToConsume.lte(new BN(0))) {
          // Already fully consumed
          (freshEvent as any)._consumptionResult = {
            consumed: alreadyConsumed.toString(),
            pending: '0',
            available_balance: '0',
            fully_consumed: true,
          };
          successEvents.push(freshEvent);
          continue; // eslint-disable-line no-continue
        }

        // Consume from grants (grant.remaining_amount is updated in-memory by consumeCredit)
        let remaining = remainingToConsume;
        let consumed = new BN(0);

        for (const grant of availableGrants) {
          if (remaining.lte(new BN(0))) break;

          // Read directly from grant object — consumeCredit() updates this.remaining_amount after each call
          const grantBalance = new BN(grant.remaining_amount);
          if (grantBalance.lte(new BN(0))) continue; // eslint-disable-line no-continue

          const consumeAmount = remaining.lte(grantBalance) ? remaining : grantBalance;

          if (consumeAmount.gt(new BN(0))) {
            await processGrantConsumption(context, grant, consumeAmount.toString(), existingTxByGrantId);
            consumed = consumed.add(consumeAmount);
            remaining = remaining.sub(consumeAmount);
          }
        }

        const totalConsumed = alreadyConsumed.add(consumed);
        const finalPending = new BN(totalRequiredAmount).sub(totalConsumed);
        const totalAvailable = availableGrants.reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0));
        const remainingBalance = totalAvailable.toString();

        if (finalPending.lte(new BN(0))) {
          (freshEvent as any)._consumptionResult = {
            consumed: totalConsumed.toString(),
            pending: '0',
            available_balance: remainingBalance,
            fully_consumed: true,
          };
          successEvents.push(freshEvent);
        } else {
          // Insufficient balance — handle events + save partial progress
          const pendingAmount = finalPending.toString();
          await handlePostConsumptionEvents(context, {
            consumed: consumed.toString(),
            remainingToConsume,
            totalAvailable,
            pendingAmount,
            remainingBalance,
            totalCreditAmountBN,
            availableGrants,
          });

          // Save partial consumption progress
          await freshEvent.update({
            credit_consumed: totalConsumed.toString(),
            credit_pending: pendingAmount,
            metadata: {
              ...freshEvent.metadata,
              consumption_result: {
                consumed: totalConsumed.toString(),
                pending: pendingAmount,
                available_balance: remainingBalance,
                fully_consumed: false,
              },
              processed_at: new Date().toISOString(),
            },
          });

          failedEvents.push({
            event: freshEvent,
            error: new Error(
              `Insufficient credit balance: required ${totalRequiredAmount}, consumed ${totalConsumed.toString()}, pending ${pendingAmount}`
            ),
          });
        }
      } catch (error: any) {
        failedEvents.push({ event: freshEvent, error });
      }
    }
    /* eslint-enable no-await-in-loop */
    // ==========================================
    // Update successful events
    // ==========================================
    if (successEvents.length > 0) {
      const now = Math.floor(Date.now() / 1000);
      await pAll(
        successEvents.map(
          (event) => () =>
            event.update({
              status: 'completed',
              processed_at: now,
              next_attempt: undefined,
              credit_consumed: (event as any)._consumptionResult?.consumed || '0',
              credit_pending: '0',
              metadata: {
                ...event.metadata,
                consumption_result: (event as any)._consumptionResult,
                last_error: undefined,
                failed_at: undefined,
                batch_processed: true,
              },
            })
        ),
        { concurrency: 5 }
      );
    }

    // Calculate actual available balance for auto-recharge
    const finalAvailableBalance = availableGrants
      .reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0))
      .toString();

    // Check low balance after batch consumption (only if all events succeeded, no insufficient already triggered)
    if (successEvents.length > 0 && failedEvents.length === 0) {
      await checkLowBalance(customerId, currencyId, totalCreditAmountBN.toString(), finalAvailableBalance, {
        meterEvent: successEvents[successEvents.length - 1]!,
        meter,
        customer,
        subscription: subscription || undefined,
        priceIds,
        transactions: [],
        warnings: [],
      });
    }

    rechargeParams = {
      customer,
      currencyId,
      availableBalance: finalAvailableBalance,
      currency: meter.paymentCurrency,
      meter,
    };

    // ==========================================
    // Retry failed events — each is independent, safe to parallelize
    // ==========================================
    await pAll(
      failedEvents.map(({ event, error: failError }) => async () => {
        try {
          const attemptCount = event.attempt_count + 1;
          if (attemptCount >= MAX_RETRY_COUNT) {
            await event.markAsRequiresAction(failError.message);
          } else {
            const nextAttemptTime = getNextRetry(attemptCount);
            await event.markAsRequiresCapture(failError.message, nextAttemptTime);
            addCreditConsumptionJob(event.id, true, { runAt: nextAttemptTime, skipStatusCheck: true });
          }
        } catch (updateError: any) {
          logger.error('Batch: failed to update meter event failure status', {
            meterEventId: event.id,
            error: updateError.message,
          });
        }
      }),
      { concurrency: 5 }
    );

    // Past due recovery
    if (subscription && subscription.status === 'past_due' && successEvents.length > 0) {
      await handlePastDueSubscriptionRecovery(subscription, null);
    }

    const totalTime = performance.now() - batchStart;
    logger.info('Batch credit consumption completed', {
      customerId,
      batchSize: meterEventIds.length,
      processable: freshProcessable.length,
      succeeded: successEvents.length,
      failed: failedEvents.length,
      grantCount: availableGrants.length,
      timing: {
        lockMs: Math.round(lockTime),
        totalMs: Math.round(totalTime),
        avgPerEvent: Math.round(totalTime / freshProcessable.length),
      },
    });
  } catch (error: any) {
    logger.error('Batch credit consumption failed, falling back to single processing', {
      customerId,
      batchSize: meterEventIds.length,
      error: error.message,
    });
    // Batch-level error: reset only events still in 'processing' to 'requires_capture'.
    // Some events may have already been completed before the error occurred.
    // Use requires_capture (not pending) so attempt_count increments, preventing infinite retry loops.
    try {
      await MeterEvent.update(
        { status: 'requires_capture' },
        { where: { id: { [Op.in]: meterEventIds }, status: 'processing' } }
      );
    } catch (resetError: any) {
      logger.error('Failed to reset event status before single fallback', { error: resetError.message });
    }
    for (const id of meterEventIds) {
      addCreditConsumptionJob(id, true, { skipStatusCheck: false });
    }
  } finally {
    lock.release();
  }

  // Auto-recharge check outside lock
  if (rechargeParams) {
    try {
      await checkAndTriggerAutoRecharge(
        rechargeParams.customer,
        rechargeParams.currencyId,
        rechargeParams.availableBalance,
        {
          currency: rechargeParams.currency,
          meter: rechargeParams.meter,
        }
      );
    } catch (error: any) {
      logger.warn('Auto recharge check failed after batch consumption', {
        customerId,
        error: error.message,
      });
    }
  }
}

// ============================================================================
// Queue setup
// ============================================================================

const creditQueueConcurrency = Math.max(
  1,
  Math.min(20, parseInt(process.env.CREDIT_QUEUE_CONCURRENCY || '5', 10) || 5)
);

export const creditQueue = createQueue<CreditConsumptionJob | BatchCreditConsumptionJob>({
  name: 'credit-consumption',
  onJob: (job) => {
    if ('meterEventIds' in job && Array.isArray((job as BatchCreditConsumptionJob).meterEventIds)) {
      return handleBatchCreditConsumption(job as BatchCreditConsumptionJob);
    }
    return handleCreditConsumption(job as CreditConsumptionJob);
  },
  options: {
    concurrency: creditQueueConcurrency,
    maxRetries: 0,
    enableScheduledJob: true,
  },
});

creditQueue.on('finished', ({ id, job }) => {
  logger.info('Credit consumption job finished', { id, job });
});

creditQueue.on('failed', ({ id, job, error }) => {
  logger.error('Credit consumption job failed', { id, job, error });

  // Error handling is now done in the main handler function
  // following payment.ts pattern with proper retry logic
});

const addCreditConsumptionJob = async (
  meterEventId: string,
  replace: boolean = false,
  options: { delay?: number; runAt?: number; skipStatusCheck?: boolean } = {}
) => {
  const jobId = `meter-event-${meterEventId}`;

  try {
    const existingJob = await creditQueue.get(jobId);
    if (existingJob && !replace) {
      logger.debug('Credit consumption job already exists, skipping duplicate', {
        meterEventId,
        jobId,
        existingJobRunAt: existingJob.runAt,
      });
      return;
    }

    if (existingJob && replace) {
      await creditQueue.delete(jobId, true);
      logger.info('Replaced existing credit consumption job', {
        meterEventId,
        jobId,
        previousRunAt: existingJob.runAt,
      });
    }

    if (!options.skipStatusCheck) {
      const meterEvent = await MeterEvent.findByPk(meterEventId);
      if (!meterEvent) {
        logger.warn('Cannot add credit consumption job: MeterEvent not found', { meterEventId });
        return;
      }

      if (meterEvent.status === 'completed' || meterEvent.status === 'canceled') {
        logger.debug('Skipping credit consumption job: MeterEvent already processed', {
          meterEventId,
          status: meterEvent.status,
        });
        return;
      }
    }

    creditQueue.push({ id: jobId, job: { meterEventId }, skipDuplicateCheck: true, ...options });
    logger.info('Credit consumption job added to queue', {
      meterEventId,
      jobId,
    });
  } catch (error: any) {
    logger.error('Failed to add credit consumption job', {
      meterEventId,
      jobId,
      error: error.message,
    });
  }
};

creditQueue.on('retry', ({ id, job }) => {
  logger.info('Retrying credit consumption job', { id, job });
});

export async function startCreditConsumeQueue(): Promise<void> {
  const lock = getLock('startCreditConsumeQueue');
  if (lock.locked) {
    return;
  }

  try {
    await lock.acquire();
    logger.info('Starting credit queue and processing pending meter events');

    // Find pending, requires_capture, and processing (stale from crash/restart) events
    // Process in batches of 50 to avoid loading too many records at once
    let totalProcessed = 0;
    let totalFailed = 0;
    let batchEvents: MeterEvent[];

    do {
      // eslint-disable-next-line no-await-in-loop
      batchEvents = await MeterEvent.findAll({
        where: {
          status: ['pending', 'requires_capture', 'processing'],
        },
        limit: 50,
        offset: totalProcessed,
      });

      // Reset stale 'processing' events back to 'pending' — these were mid-consumption
      // when the process crashed/restarted. Without reset, both single and batch handlers
      // skip 'processing' events (line ~559, ~905), causing them to be stuck forever.
      // Only reset events that have been in 'processing' for > 5 minutes to avoid
      // interfering with events actively being processed by a concurrent handler.
      const STALE_PROCESSING_THRESHOLD = 5 * 60 * 1000; // 5 minutes
      const staleProcessing = batchEvents.filter(
        (e) => e.status === 'processing' && Date.now() - new Date(e.updated_at).getTime() > STALE_PROCESSING_THRESHOLD
      );
      if (staleProcessing.length > 0) {
        // eslint-disable-next-line no-await-in-loop
        await MeterEvent.update(
          { status: 'pending' },
          { where: { id: { [Op.in]: staleProcessing.map((e) => e.id) } } }
        );
        logger.info('Reset stale processing events', {
          count: staleProcessing.length,
          eventIds: staleProcessing.map((e) => e.id),
        });
      }

      // eslint-disable-next-line no-await-in-loop
      const results = await Promise.allSettled(
        batchEvents.map(async (event) => {
          const jobId = `meter-event-${event.id}`;
          const existingJob = await creditQueue.get(jobId);
          if (!existingJob) {
            addCreditConsumptionJob(event.id, true);
          }
        })
      );

      totalFailed += results.filter((r) => r.status === 'rejected').length;
      totalProcessed += batchEvents.length;
    } while (batchEvents.length === 50);

    if (totalFailed > 0) {
      logger.warn(`Failed to queue ${totalFailed} pending meter events`);
    }

    logger.info(`Credit queue started, processed ${totalProcessed} pending events`);
  } catch (error: any) {
    logger.error('Failed to start credit queue', { error: error.message });
  } finally {
    lock.release();
  }
}

events.on('billing.meter_event.created', (meterEvent) => {
  const customerId = meterEvent.payload?.customer_id;
  if (customerId) {
    addToBatch(customerId, meterEvent.event_name, meterEvent.id, meterEvent.payload?.subscription_id);
  } else {
    addCreditConsumptionJob(meterEvent.id, true, { skipStatusCheck: true });
  }
});

events.on('customer.credit_grant.granted', async (creditGrant: CreditGrant) => {
  try {
    await retryFailedEventsForCustomer(creditGrant);
  } catch (error: any) {
    logger.error('Failed to retry failed events for customer', {
      creditGrantId: creditGrant.id,
      customerId: creditGrant.customer_id,
      error: error.message,
    });
  }
});

async function retryFailedEventsForCustomer(creditGrant: CreditGrant): Promise<void> {
  const grant = await CreditGrant.findByPk(creditGrant.id);
  if (!grant) {
    logger.error('Credit grant not found', {
      creditGrantId: creditGrant.id,
    });
    return;
  }

  const customerId = grant.customer_id;
  const currencyId = grant.currency_id;
  const lock = getLock(`retry-failed-events-${customerId}-${currencyId}`);

  try {
    await lock.acquire();

    logger.info('Retrying failed events for customer', {
      customerId,
      currencyId,
      livemode: grant.livemode,
      grantAmount: grant.amount,
      grantRemaining: grant.remaining_amount,
    });

    const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId);
    const totalAvailableCredit = availableGrants.reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0));

    const [, , failedEvents] = await MeterEvent.getPendingAmounts({
      customerId,
      currencyId,
      status: ['requires_action', 'requires_capture'],
      livemode: grant.livemode,
    });

    if (failedEvents.length === 0) {
      logger.debug('No failed events with pending credit found', {
        customerId,
        currencyId,
      });
      return;
    }

    if (totalAvailableCredit.lte(new BN(0))) {
      logger.debug('No available credit to retry failed events', {
        customerId,
        currencyId,
        totalAvailableCredit: totalAvailableCredit.toString(),
      });
      return;
    }

    const sortedEvents = failedEvents.sort((a, b) => {
      const aPending = new BN(a.credit_pending);
      const bPending = new BN(b.credit_pending);
      return aPending.cmp(bPending);
    });

    let cumulativePending = new BN(0);
    const affordableEvents: MeterEvent[] = [];

    for (const event of sortedEvents) {
      const eventPending = new BN(event.credit_pending);

      affordableEvents.push(event);
      cumulativePending = cumulativePending.add(eventPending);
      if (cumulativePending.gt(totalAvailableCredit)) {
        break;
      }
    }

    if (affordableEvents.length > 0) {
      const eventIds = affordableEvents.map((e) => e.id);
      await MeterEvent.update(
        { status: 'pending', attempt_count: 0, next_attempt: undefined },
        { where: { id: { [Op.in]: eventIds } } }
      );

      // Group by subscription_id before pushing batch jobs, because the batch handler
      // reads subscription from the first event only (line ~910) and applies its priceIds
      // to all events in the batch.
      const bySubscription = new Map<string, string[]>();
      for (const event of affordableEvents) {
        const subKey = event.getSubscriptionId() || 'default';
        const group = bySubscription.get(subKey);
        if (group) {
          group.push(event.id);
        } else {
          bySubscription.set(subKey, [event.id]);
        }
      }

      let chunkIndex = 0;
      for (const [, subEventIds] of bySubscription) {
        for (let i = 0; i < subEventIds.length; i += CREDIT_BATCH_SIZE) {
          const chunk = subEventIds.slice(i, i + CREDIT_BATCH_SIZE);
          creditQueue.push({
            id: `retry-batch-${customerId}-${Date.now()}-${chunkIndex++}`,
            job: { meterEventIds: chunk } as any,
            skipDuplicateCheck: true,
          });
        }
      }
    }

    const skippedEvents = failedEvents.length - affordableEvents.length;

    logger.info('Completed retrying failed events', {
      customerId,
      currencyId,
      totalEvents: failedEvents.length,
      affordableEvents: affordableEvents.length,
      skippedEvents,
      totalAvailableCredit: totalAvailableCredit.toString(),
      totalAffordablePending: cumulativePending.toString(),
      grantCount: availableGrants.length,
    });
  } catch (error: any) {
    logger.error('Failed to retry failed events for customer', {
      customerId: grant.customer_id,
      currencyId: grant.currency_id,
      error: error.message,
    });
  } finally {
    lock.release();
  }
}
