// create credit grant

import { Op } from 'sequelize';
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
import pAll from 'p-all';
import dayjs from '../libs/dayjs';
import createQueue from '../libs/queue';
import {
  AutoRechargeConfig,
  CreditGrant,
  Customer,
  Invoice,
  InvoiceItem,
  PaymentCurrency,
  Price,
  Product,
  TInvoiceExpanded,
  TPaymentCurrency,
  TPriceExpanded,
} from '../store/models';
import { events } from '../libs/event';
import { calculateExpiresAt, createCreditGrant } from '../libs/credit-grant';
import {
  CreditScheduleJob,
  activateAfterFirstPaymentSchedules,
  handleScheduledCredit,
  initializeCreditSchedule,
  stopCreditSchedule,
  getScheduleJobId,
} from '../libs/credit-schedule';
import { Subscription } from '../store/models/subscription';
import { getAccountState, mintToken, transferTokenFromCustomer } from '../integrations/arcblock/token';
import logger from '../libs/logger';

type CreditGrantJob =
  | {
      creditGrantId: string;
      action: 'activate' | 'expire';
    }
  | {
      invoiceId: string;
      action: 'create_from_invoice';
    }
  | CreditScheduleJob;

/**
 * Activate credit grants and handle on-chain minting when required.
 * Non on-chain credits finish immediately; on-chain credits wait for mint success
 */
async function activateGrant(creditGrant: CreditGrant) {
  const paymentCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
  const isOnChainCredit = paymentCurrency && PaymentCurrency.isOnChainCredit(paymentCurrency);

  logger.info('Activating credit grant', {
    creditGrantId: creditGrant.id,
    customerId: creditGrant.customer_id,
    currencyId: creditGrant.currency_id,
    amount: creditGrant.amount,
  });

  if (!isOnChainCredit) {
    await finalizeGrant(creditGrant);
    return;
  }

  // Skip mint for already granted credits (legacy grants or already minted)
  // New grants that need mint will have status='pending' until mint succeeds
  if (creditGrant.status === 'granted' || creditGrant.chain_status === 'mint_completed') {
    logger.info('Grant already in granted status, skipping mint', {
      creditGrantId: creditGrant.id,
      status: creditGrant.status,
      chainStatus: creditGrant.chain_status,
    });
    await finalizeGrant(creditGrant);
    return;
  }

  // Verify customer
  const customer = await Customer.findByPk(creditGrant.customer_id);
  if (!customer) {
    throw new Error(`Customer DID not found for credit grant ${creditGrant.id}`);
  }

  const customerState = await getAccountState(paymentCurrency, customer.did);

  // Step 1: Mint token on-chain
  // TODO: check if already minted
  const mintAmount = fromUnitToToken(creditGrant.amount, paymentCurrency.decimal);
  const hash = await mintToken({
    paymentCurrency,
    amount: mintAmount,
    receiver: customerState?.address || customer.did,
    creditGrantId: creditGrant.id,
  });

  // Step 2: Finalize grant with mint result
  await finalizeGrant(creditGrant, {
    chain_status: 'mint_completed',
    chain_detail: {
      ...(creditGrant.chain_detail || {}),
      mint: {
        hash,
        at: dayjs().unix(),
      },
    },
  });

  // Step 3: Emit mint success event for reconciliation
  events.emit('customer.credit_grant.minted', creditGrant, { mintHash: hash });
}

async function finalizeGrant(
  creditGrant: CreditGrant,
  updates?: Partial<Pick<CreditGrant, 'chain_status' | 'chain_detail'>>
) {
  await creditGrant.update({ ...updates, status: 'granted' });
  logger.info('Credit grant finalized', {
    creditGrantId: creditGrant.id,
    customerId: creditGrant.customer_id,
  });

  if (creditGrant.expires_at) {
    await addCreditGrantJob(creditGrant, 'expire');
    logger.info('Credit grant expire job ensured', {
      creditGrantId: creditGrant.id,
      expiresAt: creditGrant.expires_at,
    });
  }
}

/**
 * Expire credit grants and handle on-chain token transfer when required.
 * Transfers remaining tokens to system wallet for grants that have been minted (chain_status === 'mint_completed').
 * This is consistent with credit consumption - expired credits are treated the same as consumed credits.
 */
export async function expireGrant(creditGrant: CreditGrant) {
  // Only transfer for grants that have been minted on-chain
  const isOnChain = creditGrant.hasOnchainToken();

  logger.info('Expiring credit grant', {
    creditGrantId: creditGrant.id,
    isOnChain,
    chainStatus: creditGrant.chain_status,
    remainingAmount: creditGrant.remaining_amount,
  });

  // For grants without on-chain tokens, just update status
  if (!isOnChain) {
    await creditGrant.update({ status: 'expired' });
    return;
  }

  const remainingAmount = new BN(creditGrant.remaining_amount || '0');
  if (remainingAmount.lte(new BN(0))) {
    // No remaining amount to transfer, just update status
    await creditGrant.update({ status: 'expired' });
    logger.info('Credit grant expired (no remaining amount to transfer)', {
      creditGrantId: creditGrant.id,
      customerId: creditGrant.customer_id,
    });
    return;
  }

  const paymentCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
  if (!paymentCurrency) {
    logger.error('Payment currency not found for credit grant transfer', {
      creditGrantId: creditGrant.id,
      currencyId: creditGrant.currency_id,
      customerId: creditGrant.customer_id,
    });
    await creditGrant.update({ status: 'expired' });
    return;
  }

  // Get customer DID
  const customer = await Customer.findByPk(creditGrant.customer_id);
  if (!customer?.did) {
    logger.error('Customer DID not found for credit grant transfer', {
      creditGrantId: creditGrant.id,
      customerId: creditGrant.customer_id,
    });
    await creditGrant.update({ status: 'expired' });
    return;
  }

  // Transfer remaining tokens to system wallet (same as consumption flow)
  const chainDetail = creditGrant.chain_detail || {};
  const customerState = await getAccountState(paymentCurrency, customer.did);

  try {
    const hash = await transferTokenFromCustomer({
      paymentCurrency,
      customerDid: customerState?.address || customer.did,
      amount: remainingAmount.toString(),
      data: {
        reason: 'credit_expired',
        creditGrantId: creditGrant.id,
      },
      checkBalance: false, // Don't check balance as we're transferring expired tokens
    });

    logger.info('Successfully transferred expired credit tokens to system wallet', {
      creditGrantId: creditGrant.id,
      customerId: creditGrant.customer_id,
      amount: remainingAmount.toString(),
      hash,
    });

    // Update status and chain_detail with transfer result
    await creditGrant.update({
      status: 'expired',
      chain_status: 'transfer_completed',
      chain_detail: {
        ...chainDetail,
        expired_transfer: {
          hash,
          at: dayjs().unix(),
          amount: remainingAmount.toString(),
        },
        voided_reason: 'expired',
      },
    });

    // Emit expired event for reconciliation
    events.emit('customer.credit_grant.expired', creditGrant, {
      transferHash: hash,
      expiredAmount: remainingAmount.toString(),
    });
  } catch (error) {
    logger.error('Failed to transfer expired credit tokens', {
      creditGrantId: creditGrant.id,
      customerId: creditGrant.customer_id,
      amount: remainingAmount.toString(),
      error: error.message,
      stack: error.stack,
    });

    // Update status to expired and record error, but don't fail the expiration
    await creditGrant.update({
      status: 'expired',
      chain_status: 'transfer_failed',
      chain_detail: {
        ...chainDetail,
        expired_transfer: {
          ...chainDetail.expired_transfer,
          error: error.message,
          failed_at: dayjs().unix(),
        },
        voided_reason: 'expired',
      },
    });
  }
}

const handleCreditGrantJob = async (job: CreditGrantJob) => {
  logger.info('Handling credit grant job', job);

  if (job.action === 'create_from_invoice') {
    await handleInvoiceCredit(job.invoiceId);
    return;
  }

  if (job.action === 'create_from_schedule') {
    const scheduleJob = job as CreditScheduleJob;
    const nextJob = await handleScheduledCredit(scheduleJob);

    logger.info('Scheduled credit grant handled', {
      scheduleJob,
      nextJob,
    });

    // Schedule next grant if returned
    // Use replace=true because the current job (same id) is still in the DB
    // until onJobComplete deletes it, which would cause JOB_DUPLICATE error
    if (nextJob) {
      await addScheduledCreditJob(nextJob.jobId, nextJob.job, nextJob.runAt, true);
    }
    return;
  }

  const { creditGrantId, action } = job as { creditGrantId: string; action: 'activate' | 'expire' };

  const creditGrant = await CreditGrant.findByPk(creditGrantId);
  if (!creditGrant) {
    logger.error('Credit grant not found', { creditGrantId });
    return;
  }

  const now = dayjs().unix();

  if (action === 'activate') {
    if (creditGrant.status !== 'pending') {
      logger.warn('Credit grant not in pending status for activation, skipping', {
        creditGrantId,
        status: creditGrant.status,
      });
      return;
    }
    if (
      (!creditGrant.effective_at || creditGrant.effective_at <= now) &&
      (!creditGrant.expires_at || creditGrant.expires_at > now)
    ) {
      await activateGrant(creditGrant);
    }
  }

  if (action === 'expire') {
    // 检查是否到了过期时间
    if (creditGrant.status === 'expired') {
      logger.info('Credit grant already expired', {
        creditGrantId,
      });
      return;
    }
    if (creditGrant.expires_at && creditGrant.expires_at <= now) {
      await expireGrant(creditGrant);
    }
  }
};

export const creditGrantQueue = createQueue<CreditGrantJob>({
  name: 'credit-grant',
  onJob: handleCreditGrantJob,
  options: {
    concurrency: 5,
    maxRetries: 5,
    retryDelay: 5000,
    enableScheduledJob: true,
  },
});

// 添加信用授予任务
export async function addCreditGrantJob(creditGrant: CreditGrant, action: 'activate' | 'expire', runAt?: number) {
  const jobId = `${action}-${creditGrant.id}`;

  let scheduledTime: number | undefined;
  if (action === 'activate') {
    scheduledTime = runAt || creditGrant.effective_at;
  }
  if (action === 'expire') {
    scheduledTime = runAt || creditGrant.expires_at;
  }

  if (!scheduledTime) {
    logger.warn(`No scheduled time for credit grant ${action}`, {
      creditGrantId: creditGrant.id,
      action,
      effective_at: creditGrant.effective_at,
      expires_at: creditGrant.expires_at,
    });
    return;
  }

  const existingJob = await creditGrantQueue.get(jobId);
  if (existingJob) {
    await creditGrantQueue.delete(jobId);
    logger.info(`Credit grant ${action} job replaced`, { creditGrantId: creditGrant.id });
  }

  await creditGrantQueue.push({
    id: jobId,
    job: {
      creditGrantId: creditGrant.id,
      action,
    },
    runAt: scheduledTime,
    persist: true,
  });

  logger.info(`Credit grant ${action} job scheduled`, {
    creditGrantId: creditGrant.id,
    action,
    runAt: scheduledTime,
    scheduledAt: dayjs.unix(scheduledTime).format(),
  });
}

export async function scheduleCreditGrantJobs(creditGrant: CreditGrant) {
  const now = dayjs().unix();

  logger.info('Scheduling credit grant jobs', {
    creditGrantId: creditGrant.id,
    status: creditGrant.status,
    effective_at: creditGrant.effective_at,
    expires_at: creditGrant.expires_at,
  });

  if (creditGrant.status === 'pending') {
    if (creditGrant.effective_at && creditGrant.effective_at > now) {
      await addCreditGrantJob(creditGrant, 'activate');
      return;
    }
    if (!creditGrant.effective_at || creditGrant.effective_at <= now) {
      if (!creditGrant.expires_at || creditGrant.expires_at > now) {
        await activateGrant(creditGrant);
      } else {
        await expireGrant(creditGrant);
        logger.info('Credit grant immediately expired', {
          creditGrantId: creditGrant.id,
          customerId: creditGrant.customer_id,
        });
      }
    }
  }
  if (creditGrant.status === 'granted') {
    // Retry mint if previously failed, and schedule expiration
    await activateGrant(creditGrant);
  }
}

export const startCreditGrantQueue = async () => {
  logger.info('Starting credit grant queue...');

  const grantsToSchedule = await CreditGrant.findAll({
    where: {
      [Op.or]: [
        { status: 'pending' },
        {
          status: 'granted',
          expires_at: { [Op.gt]: 0 },
        },
      ],
    },
    order: [['created_at', 'ASC']],
  });

  const invoicesToSchedule = await Invoice.findAll({
    where: {
      status: 'paid',
      metadata: {
        credit_grant_processed: 'failed',
      },
    },
    order: [['created_at', 'ASC']],
  });

  const invoiceResults = await Promise.allSettled(
    invoicesToSchedule.map(async (invoice) => {
      await addInvoiceCreditJob(invoice.id);
      return invoice.id;
    })
  );

  const failedInvoices = invoiceResults.filter((r) => r.status === 'rejected').map((r) => r.reason);
  if (failedInvoices.length > 0) {
    logger.warn(`Failed to schedule jobs for ${failedInvoices.length} invoices`, {
      failedInvoices,
    });
  }

  const results = await Promise.allSettled(
    grantsToSchedule.map(async (grant) => {
      await scheduleCreditGrantJobs(grant);
      return grant.id;
    })
  );

  const failed = results.filter((r) => r.status === 'rejected').length;
  const succeeded = results.filter((r) => r.status === 'fulfilled').length;

  if (failed > 0) {
    logger.warn(`Failed to schedule jobs for ${failed} credit grants`);
  }

  // Recover credit schedule jobs for subscriptions with active schedules
  await recoverCreditScheduleJobs();

  logger.info('Credit grant queue started', {
    totalGrants: grantsToSchedule.length,
    succeeded,
    failed,
  });
};

/**
 * Recover credit schedule jobs after system restart
 * Finds subscriptions with active credit schedules and ensures their jobs are scheduled
 */
async function recoverCreditScheduleJobs(): Promise<void> {
  logger.info('Recovering credit schedule jobs...');

  try {
    // Find subscriptions with active credit schedules
    // We filter for non-null credit_schedule_state in application code
    const allActiveSubscriptions = await Subscription.findAll({
      where: {
        status: ['active', 'trialing'],
      },
    });

    // Filter for subscriptions with credit_schedule_state
    const subscriptions = allActiveSubscriptions.filter(
      (sub) => sub.credit_schedule_state && Object.keys(sub.credit_schedule_state).length > 0
    );

    if (subscriptions.length === 0) {
      logger.info('No subscriptions with credit schedules to recover');
      return;
    }

    const now = dayjs().unix();
    let recoveredCount = 0;
    let scheduledCount = 0;

    const tasks: Array<() => Promise<void>> = [];
    const recoveryConcurrency = 5;

    // Process each subscription's schedule state with bounded concurrency
    subscriptions.forEach((subscription) => {
      const state = subscription.credit_schedule_state;
      if (!state) return;

      Object.keys(state).forEach((priceId) => {
        tasks.push(async () => {
          const priceState = state[priceId];
          if (!priceState?.enabled) return;

          // Next job seq is last_grant_seq + 1
          const nextSeq = priceState.last_grant_seq + 1;
          const jobId = getScheduleJobId(subscription.id, priceId, nextSeq);
          const nextGrantAt = priceState.next_grant_at;

          // Skip if no next grant time scheduled
          if (!nextGrantAt || nextGrantAt === 0) {
            return;
          }

          // Check if job already exists
          const existingJob = await creditGrantQueue.get(jobId);

          if (nextGrantAt <= now) {
            // Job is overdue - schedule immediately with replace=true
            const job: CreditScheduleJob = {
              subscriptionId: subscription.id,
              priceId,
              scheduledAt: nextGrantAt,
              action: 'create_from_schedule',
            };
            await addScheduledCreditJob(jobId, job, now, true);
            recoveredCount++;
            logger.info('Recovered overdue credit schedule job', {
              subscriptionId: subscription.id,
              priceId,
              originalScheduledAt: nextGrantAt,
              overdueDays: Math.floor((now - nextGrantAt) / 86400),
            });
          } else if (!existingJob) {
            // Job is in future but not scheduled - create it
            const job: CreditScheduleJob = {
              subscriptionId: subscription.id,
              priceId,
              scheduledAt: nextGrantAt,
              action: 'create_from_schedule',
            };
            await addScheduledCreditJob(jobId, job, nextGrantAt, false);
            scheduledCount++;
            logger.debug('Scheduled missing credit schedule job', {
              subscriptionId: subscription.id,
              priceId,
              scheduledAt: dayjs.unix(nextGrantAt).format(),
            });
          }
        });
      });
    });

    await pAll(tasks, { concurrency: recoveryConcurrency, stopOnError: false });

    logger.info('Credit schedule jobs recovery completed', {
      subscriptionsChecked: subscriptions.length,
      overdueJobsRecovered: recoveredCount,
      missingJobsScheduled: scheduledCount,
    });
  } catch (error: any) {
    logger.error('Failed to recover credit schedule jobs', {
      error: error.message,
      stack: error.stack,
    });
  }
}

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

creditGrantQueue.on('finished', ({ id }) => {
  logger.debug('Credit grant job completed', { id });
});

async function handleInvoiceCredit(invoiceId: string) {
  try {
    const invoice = (await Invoice.findByPk(invoiceId, {
      include: [
        {
          model: Customer,
          as: 'customer',
        },
        {
          model: InvoiceItem,
          as: 'lines',
        },
      ],
    })) as TInvoiceExpanded | null;

    if (!invoice) {
      logger.warn('Invoice not found', { invoiceId });
      return;
    }

    if (!invoice.customer) {
      logger.warn('customer not found', { invoiceId });
      return;
    }

    // Handle auto recharge invoice - use standard processing since we now create proper InvoiceItems
    if (invoice.metadata?.auto_recharge) {
      logger.info('Processing auto recharge invoice with standard logic', {
        invoiceId: invoice.id,
        customerId: invoice.customer.id,
      });
      if (invoice.metadata?.auto_recharge?.config_id) {
        const autoRechargeConfig = await AutoRechargeConfig.findByPk(invoice.metadata?.auto_recharge?.config_id);
        if (autoRechargeConfig) {
          await autoRechargeConfig.updateDailyStats(invoice.total);
        }
      }
    }

    const lineItems = invoice.lines || [];

    if (!Array.isArray(lineItems) || lineItems.length === 0) {
      logger.info('No line items found in invoice', { invoiceId });
      return;
    }

    const items = await Promise.all(
      lineItems.map(async (lineItem) => {
        const price = (await Price.findByPk(lineItem.price_id, {
          include: [{ model: Product, as: 'product' }],
        })) as TPriceExpanded | null;

        if (!price) {
          logger.warn('Price not found', { priceId: lineItem.price_id, invoiceId });
          return null;
        }

        if (!price.product || price.product.type !== 'credit') {
          return null;
        }

        const metadata = price.metadata || {};
        const creditConfig = metadata.credit_config || {};

        // Check if this price uses schedule-based delivery
        // If delivery_mode is 'schedule', skip invoice-based credit grant creation
        const scheduleConfig = creditConfig.schedule;
        if (scheduleConfig?.enabled && scheduleConfig?.delivery_mode === 'schedule') {
          logger.info('Skipping invoice credit grant for schedule-based price', {
            invoiceId,
            priceId: price.id,
            deliveryMode: scheduleConfig.delivery_mode,
          });
          return null;
        }

        const quantity = lineItem.quantity || 1;
        const currency = await PaymentCurrency.findByPk(creditConfig.currency_id);

        if (!currency) {
          logger.error('Currency not found', { currencyId: creditConfig.currency_id, invoiceId });
          throw new Error(`Currency not found: ${creditConfig.currency_id}`);
        }

        const creditAmountPerUnit = fromTokenToUnit(creditConfig.credit_amount ?? '1', currency.decimal);
        const creditAmount = new BN(creditAmountPerUnit).mul(new BN(quantity)).toString();

        let applicabilityConfig: any = {
          scope: {
            type: 'metered',
          },
        };

        let applicablePrices: string[] = [];
        if (creditConfig.applicable_prices) {
          applicablePrices = Array.isArray(creditConfig.applicable_prices)
            ? creditConfig.applicable_prices
            : creditConfig.applicable_prices.split(',');
        }

        if (applicablePrices.length > 0) {
          applicabilityConfig = {
            scope: {
              prices: applicablePrices,
            },
          };
        }

        return {
          price,
          quantity,
          metadata,
          creditAmount,
          applicablePrices,
          expiredAt: calculateExpiresAt(
            // eslint-disable-next-line no-unsafe-optional-chaining
            +creditConfig?.valid_duration_value || 0,
            creditConfig?.valid_duration_unit || 'days'
          ),
          applicabilityConfig,
          name: price.nickname || price.product.name,
          currency: currency as TPaymentCurrency,
        };
      })
    );

    const validItems = items.filter((item): item is NonNullable<typeof item> => item !== null);

    const processedMetadata = {
      ...invoice.metadata,
      credit_grant_processed: 'completed',
      credit_grant_completed_at: dayjs().unix(),
    };

    if (validItems.length === 0) {
      logger.info('No credit products found in invoice', { invoiceId });
      return;
    }

    const existingGrants = await CreditGrant.findAll({
      where: {
        customer_id: invoice.customer.id,
        metadata: {
          invoice_id: invoice.id,
        },
      },
    });

    const existingPriceIds = new Set(existingGrants.map((grant) => grant.metadata?.price_id).filter(Boolean));
    const grantsToCreate = validItems.filter((item) => !existingPriceIds.has(item.price.id));

    if (grantsToCreate.length === 0) {
      logger.info('All credit grants already exist for invoice', {
        invoiceId,
        totalItems: validItems.length,
        existingCount: existingGrants.length,
      });
      // 标记为已完成
      await Invoice.update(
        {
          metadata: processedMetadata,
        },
        { where: { id: invoiceId } }
      );
      return;
    }

    // 创建 Credit Grants
    await Invoice.update(
      {
        metadata: {
          ...invoice.metadata,
          credit_grant_processed: 'processing',
        },
      },
      { where: { id: invoiceId } }
    );

    try {
      const createPromises = grantsToCreate.map(async (item) => {
        const creditGrant = await createCreditGrant({
          amount: item.creditAmount,
          currency_id: item.currency.id,
          customer_id: invoice.customer.id,
          name: item.price.nickname || item.price.product?.name,
          category: 'paid',
          priority: parseInt(String(item.metadata.credit_config?.priority || '50'), 10),
          expires_at: item.expiredAt || undefined,
          applicability_config: item.applicabilityConfig,
          livemode: invoice.livemode,
          created_via: 'invoice',
          metadata: {
            price_id: item.price.id,
            invoice_id: invoice.id,
            purchased_quantity: item.quantity,
            subscription_id: invoice.subscription_id,
          },
        });

        logger.info('Credit Grant created from invoice', {
          invoiceId,
          customerId: invoice.customer.id,
          productId: item.price.product_id,
          priceId: item.price.id,
          creditAmount: item.creditAmount.toString(),
          expiresAt: item.expiredAt,
          creditGrantId: creditGrant.id,
        });

        // Credit grant activation is handled by the customer.credit_grant.created event
        // listener via createEvent() in the afterCreate hook. createEvent() self-registers
        // in __cfPendingJobs__ so it completes in CF Workers before the request ends.

        return creditGrant;
      });
      await Promise.all(createPromises);
    } catch (error) {
      logger.error('Failed to create Credit Grant from invoice', {
        invoiceId,
        error,
        stack: error.stack,
      });
      await Invoice.update(
        {
          metadata: {
            ...invoice.metadata,
            credit_grant_processed: 'failed',
          },
        },
        { where: { id: invoiceId } }
      );
      throw error;
    }

    // 所有创建成功，标记为已完成
    await Invoice.update(
      {
        metadata: processedMetadata,
      },
      { where: { id: invoiceId } }
    );

    logger.info('All Credit Grants created successfully for invoice', {
      invoiceId,
      createdCount: grantsToCreate.length,
      totalCreditItems: validItems.length,
    });
  } catch (error) {
    logger.error('Failed to create Credit Grant from invoice', {
      invoiceId,
      error: error.message,
      stack: error.stack,
    });
    throw error;
  }
}

export async function addInvoiceCreditJob(invoiceId: string) {
  const jobId = `invoice-credit-${invoiceId}`;

  const existingJob = await creditGrantQueue.get(jobId);
  if (existingJob) {
    logger.debug('Invoice credit job already exists, skipping duplicate', {
      invoiceId,
      jobId,
    });
    return;
  }

  await creditGrantQueue.push({
    id: jobId,
    job: {
      invoiceId,
      action: 'create_from_invoice',
    },
    persist: true,
  });

  logger.info('Invoice credit job scheduled', {
    invoiceId,
    jobId,
  });
}

/**
 * Add a scheduled credit job to the queue
 * @param jobId Unique job ID (typically schedule-{subscriptionId}-{priceId})
 * @param job The credit schedule job data
 * @param runAt Unix timestamp when the job should run
 * @param replace If true, replace existing job; if false, skip if job exists
 */
export async function addScheduledCreditJob(
  jobId: string,
  job: CreditScheduleJob,
  runAt: number,
  replace: boolean = false
): Promise<boolean> {
  // Always try to delete existing job first to avoid JOB_DUPLICATE error
  // This is necessary because when a job is executing and creates the next job,
  // the current job hasn't been deleted yet, causing ID conflict
  const existingJob = await creditGrantQueue.get(jobId);

  if (existingJob) {
    if (replace) {
      await creditGrantQueue.delete(jobId);
      logger.info('Scheduled credit job replaced', { jobId, runAt });
    } else {
      logger.debug('Scheduled credit job already exists, skipping', {
        jobId,
        existingRunAt: existingJob.runAt,
        newRunAt: runAt,
      });
      return false;
    }
  }

  // Use store.addJob directly to ensure proper persistence and error handling
  // instead of relying on push() which has async issues for scheduled jobs
  try {
    const now = dayjs().unix();
    const attrs: { delay?: number; will_run_at?: number } = {};

    // If runAt is in the future, create as scheduled job
    if (runAt > now) {
      attrs.delay = 1;
      attrs.will_run_at = runAt * 1000; // Convert to milliseconds
    }

    await creditGrantQueue.store.addJob(jobId, job, attrs);

    logger.info('Scheduled credit job added', {
      jobId,
      subscriptionId: job.subscriptionId,
      priceId: job.priceId,
      runAt,
      scheduledAt: dayjs.unix(runAt).format(),
      isImmediate: runAt <= now,
    });

    // If job should run immediately (runAt <= now), push to queue for execution
    if (runAt <= now) {
      creditGrantQueue.push({
        id: jobId,
        job,
        persist: false, // Already persisted above
      });
      logger.info('Scheduled credit job pushed for immediate execution', { jobId });
    }

    return true;
  } catch (err: any) {
    // Handle duplicate job error gracefully
    if (err.code === 'JOB_DUPLICATE') {
      logger.warn('Scheduled credit job already exists (concurrent creation)', {
        jobId,
        error: err.message,
      });
      return false;
    }
    logger.error('Failed to add scheduled credit job', {
      jobId,
      error: err.message,
      stack: err.stack,
    });
    throw err;
  }
}

/**
 * Delete a scheduled credit job from the queue
 */
export async function deleteScheduledCreditJob(jobId: string): Promise<boolean> {
  const existingJob = await creditGrantQueue.get(jobId);
  if (existingJob) {
    await creditGrantQueue.delete(jobId);
    logger.info('Scheduled credit job deleted', { jobId });
    return true;
  }
  return false;
}

events.on('invoice.paid', async (invoice: Invoice) => {
  try {
    await addInvoiceCreditJob(invoice.id);
    if (invoice.subscription_id) {
      const subscription = await Subscription.findByPk(invoice.subscription_id);
      if (subscription) {
        const jobs = await activateAfterFirstPaymentSchedules(subscription, invoice.status_transitions?.paid_at);
        await Promise.all(jobs.map((job) => addScheduledCreditJob(job.jobId, job.job, job.runAt, true)));
      }
    }
  } catch (error) {
    logger.error('Failed to schedule credit grant job for invoice', {
      invoiceId: invoice.id,
      error: error.message,
    });
  }
});

events.on('customer.credit_grant.created', async (data: { id: string }) => {
  try {
    // Fetch instance since event emits dataValues (plain object)
    const creditGrant = await CreditGrant.findByPk(data.id);
    if (!creditGrant) {
      logger.error('Credit grant not found for scheduling', { creditGrantId: data.id });
      return;
    }
    await scheduleCreditGrantJobs(creditGrant);
    logger.info('Credit grant jobs scheduled', {
      creditGrantId: creditGrant.id,
      customerId: creditGrant.customer_id,
    });
  } catch (error) {
    logger.error('Failed to schedule credit grant jobs', {
      creditGrantId: data.id,
      error,
    });
  }
});

events.on('credit-grant.queued', async (id, job, args = {}) => {
  const { sync, ...extraArgs } = args;
  if (sync) {
    try {
      await creditGrantQueue.pushAndWait({
        id,
        job,
        ...extraArgs,
      });
      events.emit('credit-grant.queued.done', { id });
    } catch (error) {
      logger.error('Error in credit-grant.queued', { id, job, error });
      events.emit('credit-grant.queued.error', { id, job, error });
    }
    return;
  }
  try {
    const existJob = await creditGrantQueue.get(id);
    if (existJob) {
      await creditGrantQueue.delete(id);
      logger.info('Removed existing invoice job for immediate execution', {
        id,
        originalRunAt: existJob.runAt,
      });
    }
    creditGrantQueue.push({
      id,
      job,
      ...extraArgs,
    });
    events.emit('credit-grant.queued.done', { id });
  } catch (error) {
    logger.error('Error in credit-grant.queued', { id, job, error });
    events.emit('credit-grant.queued.error', { id, job, error });
  }
});

// ========================================
// Credit Schedule Event Handlers
// ========================================

/**
 * Helper function to initialize credit schedule and create jobs
 * Used by both subscription.created and subscription.started events
 */
async function initializeAndScheduleCreditJobs(subscriptionId: string, eventName: string): Promise<void> {
  const sub = await Subscription.findByPk(subscriptionId);
  if (!sub) {
    logger.warn('Subscription not found for credit schedule initialization', {
      subscriptionId,
      event: eventName,
    });
    return;
  }

  // initializeCreditSchedule only works for active/trialing subscriptions
  // and skips if already initialized
  const state = await initializeCreditSchedule(sub);
  if (!state) {
    logger.debug('No credit schedules to initialize for subscription', {
      subscriptionId,
      event: eventName,
      status: sub.status,
    });
    return;
  }

  // Schedule jobs for prices with valid next_grant_at
  const pricesToSchedule = Object.keys(state).filter((priceId) => {
    const priceState = state[priceId];
    return priceState?.enabled && priceState?.next_grant_at > 0;
  });

  await Promise.all(
    pricesToSchedule.map((priceId) => {
      const priceState = state[priceId]!;
      // First job has seq = 1 (since last_grant_seq starts at 0)
      const nextSeq = priceState.last_grant_seq + 1;
      const jobId = getScheduleJobId(subscriptionId, priceId, nextSeq);
      const job: CreditScheduleJob = {
        subscriptionId,
        priceId,
        scheduledAt: priceState.next_grant_at,
        action: 'create_from_schedule',
      };
      return addScheduledCreditJob(jobId, job, priceState.next_grant_at, true);
    })
  );

  logger.info('Credit schedule initialized and jobs scheduled', {
    subscriptionId,
    event: eventName,
    priceCount: pricesToSchedule.length,
  });
}

/**
 * Handle subscription started event (when subscription becomes active)
 *
 * This event fires when:
 * 1. Subscription payment succeeds (checkout flow completes)
 * 2. Trial ends and subscription becomes active
 * 3. Stripe SetupIntent succeeds
 *
 * For subscriptions created via checkout (status was incomplete),
 * this is when the credit schedule gets initialized.
 */
events.on('customer.subscription.started', async (subscription: Subscription) => {
  try {
    await initializeAndScheduleCreditJobs(subscription.id, 'customer.subscription.started');
  } catch (error: any) {
    logger.error('Failed to handle credit schedule on subscription started', {
      subscriptionId: subscription.id,
      error: error.message,
    });
  }
});

/**
 * Handle subscription trial start event (when subscription enters trialing state)
 *
 * This event fires when subscription has a trial period and payment succeeds.
 * For immediate timing, credit schedule should start during trial period.
 */
events.on('customer.subscription.trial_start', async (subscription: Subscription) => {
  try {
    await initializeAndScheduleCreditJobs(subscription.id, 'customer.subscription.trial_start');
  } catch (error: any) {
    logger.error('Failed to handle credit schedule on subscription trial start', {
      subscriptionId: subscription.id,
      error: error.message,
    });
  }
});

/**
 * Handle subscription deleted event - stop all credit schedules
 */
events.on('customer.subscription.deleted', async (subscription: Subscription) => {
  try {
    const sub = await Subscription.findByPk(subscription.id);
    if (!sub) {
      return;
    }

    const jobIds = await stopCreditSchedule(sub);

    // Delete all scheduled jobs
    await Promise.all(jobIds.map((id) => deleteScheduledCreditJob(id)));

    logger.info('Credit schedules stopped on subscription deletion', {
      subscriptionId: subscription.id,
      deletedJobs: jobIds.length,
    });
  } catch (error: any) {
    logger.error('Failed to stop credit schedules on subscription deletion', {
      subscriptionId: subscription.id,
      error: error.message,
    });
  }
});
