/* eslint-disable no-await-in-loop */
import component from '@blocklet/sdk/lib/component';
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim';
import pick from 'lodash/pick';
import type { LiteralUnion } from 'type-fest';
import { withQuery } from 'ufo';

import { Op } from 'sequelize';
import {
  ChainType,
  Customer,
  Invoice,
  InvoiceItem,
  Lock,
  PaymentCurrency,
  PaymentIntent,
  PaymentMethod,
  Price,
  PriceRecurring,
  Refund,
  Subscription,
  SubscriptionData,
  SubscriptionItem,
  SubscriptionUpdateItem,
  TLineItemExpanded,
  UsageRecord,
} from '../store/models';
import { createEvent } from './audit';
import dayjs from './dayjs';
import env from './env';
import logger from './logger';
import { getExchangeRateService } from './exchange-rate';
import { getExchangeRateSymbol } from './exchange-rate/token-address-mapping';
import { trimDecimals, limitTokenPrecision } from './math-utils';
import { getPriceCurrencyOptions, getPriceUintAmountByCurrency } from './price';
import { getRecurringPeriod, getSubscriptionCreateSetup, SlippageOptions } from './session';
import { getConnectQueryParam, getCustomerStakeAddress } from './util';
import { getStakingAddressWithFallback } from './wallet-migration';
import { wallet } from './auth';
import { getGasPayerExtra } from './payment';
import { getLock } from './lock';
import { emitAsync } from './event';
import { getSubscriptionItemPrice } from './credit-utils';

export function getCustomerSubscriptionPageUrl({
  subscriptionId,
  locale = 'en',
  userDid,
}: {
  subscriptionId: string;
  locale: LiteralUnion<'en' | 'zh', string>;
  userDid: string;
}) {
  return component.getUrl(
    withQuery(`customer/subscription/${subscriptionId}`, {
      locale,
      ...getConnectQueryParam({ userDid }),
    })
  );
}

export function getAdminSubscriptionPageUrl({
  subscriptionId,
  locale = 'en',
  userDid,
}: {
  subscriptionId: string;
  locale: LiteralUnion<'en' | 'zh', string>;
  userDid: string;
}) {
  return component.getUrl(
    withQuery(`admin/billing/${subscriptionId}`, {
      locale,
      ...getConnectQueryParam({ userDid }),
    })
  );
}

export function getAdminInvoicePageUrl({
  invoiceId,
  locale = 'en',
  userDid,
}: {
  invoiceId: string;
  locale: LiteralUnion<'en' | 'zh', string>;
  userDid: string;
}) {
  return component.getUrl(
    withQuery(`admin/billing/${invoiceId}`, {
      locale,
      ...getConnectQueryParam({ userDid }),
    })
  );
}

export function parseIntegerConfig(alternatives: any[], defaultValue: number) {
  for (const raw of alternatives) {
    const days = parseInt(raw, 10);
    if (typeof days === 'number' && days >= 0) {
      return days;
    }
  }

  return defaultValue;
}

export function getDaysUntilDue(query: Record<string, any> = {}) {
  return parseIntegerConfig([query.days_until_due, process.env.PAYMENT_DAYS_UNTIL_DUE], 6);
}

export function getDaysUntilCancel(query: Record<string, any> = {}) {
  return parseIntegerConfig([query.days_until_cancel, process.env.PAYMENT_DAYS_UNTIL_CANCEL], 0);
}

export const getDueUnit = (interval: string) => {
  if (interval === 'hour') {
    return 60;
  }

  if (interval === 'day') {
    return 60 * 60;
  }

  return 60 * 60 * 24;
};

export const getMaxRetryCount = (interval: string) => {
  if (interval === 'hour') {
    return 10; // 34 minutes
  }

  if (interval === 'day') {
    return 15; // 18 hours
  }

  if (interval === 'week') {
    return 18; // 6 days
  }

  return 20; // 24 days
};

export const getMinRetryMail = (interval: string) => {
  if (interval === 'hour') {
    return 9; // 17 minutes
  }

  if (interval === 'day') {
    return 13; // 4 hours
  }

  if (interval === 'week') {
    return 15; // 18 hours
  }

  return 15; // 18 hours
};

const ZERO = new BN(0);
export function getSubscriptionStakeSetup(items: TLineItemExpanded[], currencyId: string, billingThreshold = '0') {
  const staking = {
    licensed: new BN(0),
    metered: new BN(0),
  };

  const recurringItems = items
    .map((x) => x.upsell_price || x.price)
    .filter((x) => x.type === 'recurring' && x.recurring);
  if (recurringItems.length > 0) {
    if (new BN(billingThreshold).gt(ZERO)) {
      staking.licensed = new BN(billingThreshold);
    } else {
      items.forEach((x) => {
        const price = getSubscriptionItemPrice(x);
        const unit = getPriceUintAmountByCurrency(price, currencyId);
        const amount = new BN(unit).mul(new BN(x.quantity));
        if (price.type === 'recurring' && price.recurring) {
          if (price.recurring.usage_type === 'licensed') {
            staking.licensed = staking.licensed.add(amount);
          }
          if (price.recurring.usage_type === 'metered') {
            staking.metered = staking.metered.add(amount);
          }
        }
      });
    }
  }

  return staking;
}

export function getSubscriptionCycleSetup(
  recurring: PriceRecurring,
  previousPeriodEnd: number,
  options?: {
    catchUp?: boolean;
    maxMissedPeriods?: number;
  }
) {
  const cycle = getRecurringPeriod(recurring);
  const now = dayjs().unix();

  // Basic setup without catch-up
  if (!options?.catchUp) {
    return {
      recurring,
      cycle,
      period: {
        start: previousPeriodEnd,
        end: dayjs.unix(previousPeriodEnd).add(cycle, 'millisecond').unix(),
      },
      missedPeriods: 0,
    };
  }

  // Calculate missed periods if catch-up is enabled
  const expectedEnd = dayjs.unix(previousPeriodEnd).add(cycle, 'millisecond').unix();

  if (now <= expectedEnd) {
    // No catch-up needed
    return {
      recurring,
      cycle,
      period: {
        start: previousPeriodEnd,
        end: expectedEnd,
      },
      missedPeriods: 0,
    };
  }

  // Calculate how many periods we've missed
  const timeSinceExpected = now - expectedEnd;
  const cycleInSeconds = cycle / 1000;
  const missedPeriods = Math.floor(timeSinceExpected / cycleInSeconds);
  const maxMissedPeriods = options.maxMissedPeriods || 100; // Default limit to prevent excessive catch-up

  const actualMissedPeriods = Math.min(missedPeriods, maxMissedPeriods);

  // Calculate the current period we should be in
  const currentPeriodStart = dayjs
    .unix(previousPeriodEnd)
    .add((actualMissedPeriods + 1) * cycle, 'millisecond')
    .unix();
  const currentPeriodEnd = dayjs.unix(currentPeriodStart).add(cycle, 'millisecond').unix();

  return {
    recurring,
    cycle,
    period: {
      start: currentPeriodStart,
      end: currentPeriodEnd,
    },
    missedPeriods: actualMissedPeriods,
    // Additional info for recovery handling
    recovery: {
      originalPeriodEnd: previousPeriodEnd,
      expectedPeriodEnd: expectedEnd,
      timeMissed: timeSinceExpected,
      periodsSkipped: actualMissedPeriods,
      wasLimited: missedPeriods > maxMissedPeriods,
    },
  };
}

export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyId: string) {
  let amount = new BN(0);

  items.forEach((x) => {
    const price = getSubscriptionItemPrice(x) as any;
    const dynamicAmount =
      price?.pricing_type === 'dynamic' ? (x as any).custom_amount || (x as any).quoted_amount : null;

    if (dynamicAmount) {
      amount = amount.add(new BN(dynamicAmount));
      return;
    }

    amount = amount.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
  });

  return {
    total: amount.toString(),
  };
}

// Re-exported from credit-utils.ts to maintain backward compatibility
export { getSubscriptionItemPrice } from './credit-utils';

export async function createProration(
  subscription: Subscription,
  setup: ReturnType<typeof getSubscriptionCreateSetup>,
  anchor: number
) {
  const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
  if (!lastInvoice) {
    throw new Error('Subscription should have latest invoice when create proration');
  }

  const customer = await Customer.findByPk(subscription.customer_id);
  if (!customer) {
    throw new Error('Subscription should have customer when create proration');
  }

  // 1. get last invoice, and invoice items, filter invoice items that are in licensed recurring mode
  const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: lastInvoice.id, proration: false } });
  const invoiceItemsExpanded = await Price.expand(invoiceItems.map((x) => x.toJSON()));
  const prorationItems = invoiceItemsExpanded.filter((x) => {
    const price = getSubscriptionItemPrice(x);
    return price.type === 'recurring' && price.recurring?.usage_type === 'licensed';
  });

  // 2. calculate proration args based on the filtered invoice items
  const precision = 10000;
  const isInvoicePeriodValid = lastInvoice.period_start < lastInvoice.period_end;
  const prorationStart = isInvoicePeriodValid ? lastInvoice.period_start : subscription.current_period_start;
  const prorationEnd = isInvoicePeriodValid ? lastInvoice.period_end : subscription.current_period_end;
  if (anchor > prorationEnd) {
    logger.warn('try to create proration with invalid arguments', { anchor, prorationStart, prorationEnd });
    throw new Error('Subscription proration anchor should not be larger than prorationEnd');
  }
  const trialing = subscription.status === 'trialing';
  if (trialing) {
    return {
      lastInvoice,
      total: '0',
      shouldPay: '0',
      used: '0',
      unused: '0',
      prorations: [],
      newCredit: '0',
      appliedCredit: '0',
      remaining: '0',
      remainingUnused: '0',
    };
  }

  const prorationRate = Math.ceil(((prorationEnd - anchor) / (prorationEnd - prorationStart)) * precision);
  let unused = new BN(0);
  const prorations = await Promise.all(
    prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
      const price = getSubscriptionItemPrice(x);
      // For dynamic pricing, use the actual paid amount from invoice item
      // For fixed pricing, use the standard unit_amount calculation
      let baseAmount: string;
      if (price.pricing_type === 'dynamic') {
        // Use invoice item's actual amount (from Quote)
        baseAmount = x.amount || '0';
        logger.info('Using dynamic pricing amount for proration', {
          subscriptionId: subscription.id,
          priceId: price.id,
          actualAmount: baseAmount,
        });
      } else {
        const unitAmount = getPriceUintAmountByCurrency(price, lastInvoice.currency_id);
        baseAmount = new BN(unitAmount).mul(new BN(x.quantity)).toString();
      }

      const amount = new BN(baseAmount).mul(new BN(prorationRate)).div(new BN(precision)).toString();
      logger.info('subscription proration item', {
        subscription: subscription.id,
        invoice: x.invoice_id,
        invoiceItem: x.id,
        amount,
        isDynamic: price.pricing_type === 'dynamic',
      });
      unused = unused.add(new BN(amount));

      return {
        price_id: price.id,
        amount: `-${amount}`,
        quantity: x.quantity,
        // @ts-ignore
        description: `Unused time on ${price.product.name} after ${dayjs().format('lll')}`,
        period: {
          start: lastInvoice.period_start,
          end: lastInvoice.period_end,
        },
        proration: true,
        proration_details: {
          credited_items: {
            invoice_id: lastInvoice.id,
            // @ts-ignore
            invoice_line_items: [x.id],
          },
        },
        // Additional info for frontend to recalculate with current exchange rate
        pricing_type: price.pricing_type,
        base_amount_usd: price.pricing_type === 'dynamic' ? price.base_amount : null,
        proration_rate: prorationRate / precision, // e.g., 0.85 means 85% unused time
      };
    })
  );
  logger.info('subscription prorations created', {
    subscription: subscription.id,
    prorations,
  });

  // 5. adjust invoice total && update customer token balance
  const total = setup.amount.setup;

  // 6. calculate remaining amount
  const refunds = await Refund.findAll({
    where: {
      status: { [Op.not]: 'canceled' },
      subscription_id: subscription.id,
      currency_id: lastInvoice?.currency_id,
      invoice_id: lastInvoice?.id,
    },
  });
  const refundAmount = refunds.reduce((acc, x) => acc.add(new BN(x.amount || '0')), new BN(0));

  // 7. calculate remaining amount, default refund recurring amount
  const calcRemaining = (amount: BN, subtract: BN) =>
    amount.sub(subtract).lt(new BN(0)) ? '0' : amount.sub(subtract).toString();

  const remaining = calcRemaining(new BN(total), refundAmount);
  const remainingUnused = calcRemaining(unused, refundAmount);

  let due = setup.amount.setup;
  let newCredit = '0';
  let appliedCredit = '0';
  if (new BN(total).gte(unused)) {
    // Proration amount is less than total, all proration are used,
    due = new BN(total).sub(unused).toString();
    // Besides, we need to try to apply customer credit
    appliedCredit = customer.getBalanceToApply(subscription.currency_id, due);
    due = new BN(due).sub(new BN(appliedCredit)).toString();
  } else {
    // Proration amount is greater than total, we need to increase customer credit
    newCredit = unused.sub(new BN(total)).toString();
    due = '0';
  }

  logger.info('subscription proration result', {
    subscription: subscription.id,
    prorationStart,
    prorationEnd,
    prorationRate,
    unused: unused.toString(),
    remaining,
    remainingUnused,
    total,
    due,
    newCredit,
    appliedCredit,
  });

  return {
    lastInvoice,
    total,
    due,
    remaining,
    remainingUnused,
    used: new BN(lastInvoice.amount_due).sub(unused).toString(),
    unused: unused.toString(),
    prorations,
    newCredit,
    appliedCredit,
  };
}

export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number, currencyId?: string) {
  const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
  const expanded = await Price.expand(items.map((x) => x.toJSON()));
  const targetCurrencyId = currencyId || subscription.currency_id;

  // Build slippage options with min_acceptable_rate for precise authorization calculation
  const slippageConfig = subscription.slippage_config;
  const currency = await PaymentCurrency.findByPk(targetCurrencyId);
  const slippageOptions: SlippageOptions = {
    percent: slippageConfig?.percent ?? 0.5,
    minAcceptableRate: slippageConfig?.min_acceptable_rate,
    currencyDecimal: currency?.decimal,
  };
  const setup = getSubscriptionCreateSetup(expanded, targetCurrencyId, 0, 0, slippageOptions);
  return createProration(subscription, setup, anchor);
}

export function shouldCancelSubscription(
  subscription: Pick<Subscription, 'status' | 'current_period_end' | 'cancel_at' | 'cancel_at_period_end'>
) {
  if (['past_due', 'active', 'trialing'].includes(subscription.status)) {
    const now = dayjs().unix();
    if (subscription.cancel_at) {
      if (subscription.cancel_at <= now) {
        return true;
      }
    } else if (subscription.cancel_at_period_end) {
      if (subscription.current_period_end <= now) {
        return true;
      }
    }
  }

  return false;
}

export async function canChangePaymentMethod(subscriptionId: string) {
  const item = await SubscriptionItem.findOne({ where: { subscription_id: subscriptionId } });
  const expanded = await Price.findOne({ where: { id: item!.price_id } });
  return expanded && getPriceCurrencyOptions(expanded).length > 1;
}

export async function expandSubscriptionItems(subscriptionId: string) {
  const items = await SubscriptionItem.findAll({ where: { subscription_id: subscriptionId } });
  return Price.expand(items.map((x) => x.toJSON()));
}

export async function getUpcomingInvoiceAmount(subscriptionId: string) {
  const subscription = await Subscription.findByPk(subscriptionId);
  if (!subscription) {
    throw new Error('Subscription not found');
  }

  if (subscription.isActive() === false && subscription.status !== 'past_due') {
    throw new Error(`Subscription not active for ${subscriptionId}, so usage check is skipped`);
  }

  const currency = await PaymentCurrency.findByPk(subscription.currency_id);

  const items = await SubscriptionItem.findAll({ where: { subscription_id: subscriptionId } });
  const expanded = await Price.expand(items.map((x) => x.toJSON()));

  let amount = new BN(0);
  let minExpectedAmount = new BN(0);
  let hasDynamicPricing = false;

  for (const item of expanded) {
    const price = getSubscriptionItemPrice(item);
    if (price.pricing_type === 'dynamic') {
      hasDynamicPricing = true;
    }
    if (price.type === 'recurring') {
      const unit = getPriceUintAmountByCurrency(price, subscription.currency_id);
      if (price.recurring?.usage_type === 'licensed') {
        amount = amount.add(new BN(unit).mul(new BN(item.quantity)));
        minExpectedAmount = minExpectedAmount.add(new BN(unit).mul(new BN(item.quantity)));
      }
      if (price.recurring?.usage_type === 'metered') {
        const rawQuantity = await UsageRecord.getSummary({
          // @ts-ignore
          id: item?.id as string,
          start: subscription.current_period_start,
          end: subscription.current_period_end,
          method: price.recurring?.aggregate_usage as any,
          dryRun: true,
        });
        // @ts-ignore
        const quantity = price.transformQuantity(rawQuantity);
        amount = amount.add(new BN(unit).mul(new BN(quantity)));
        minExpectedAmount = minExpectedAmount.add(new BN(unit).mul(new BN(item.quantity)));
      }
    }
  }

  // Get exchange rate info for dynamic pricing subscriptions
  let quoteInfo: {
    exchange_rate: string;
    rate_timestamp_ms: number;
    providers: Array<{ provider_name: string; rate?: string }>;
  } | null = null;

  if (hasDynamicPricing && currency) {
    try {
      const paymentMethod = await PaymentMethod.findByPk(currency.payment_method_id);
      const exchangeRateService = getExchangeRateService();
      const rateSymbol = getExchangeRateSymbol(currency.symbol, paymentMethod?.type as ChainType);
      if (rateSymbol) {
        const rateResult = await exchangeRateService.getRate(rateSymbol);
        if (rateResult?.rate) {
          quoteInfo = {
            exchange_rate: rateResult.rate,
            rate_timestamp_ms: rateResult.timestamp_ms || Date.now(),
            providers: rateResult.providers || [],
          };
        }
      }
    } catch (err) {
      logger.warn('Failed to fetch exchange rate for upcoming invoice', {
        subscriptionId,
        error: err,
      });
    }
  }

  return {
    amount: amount.toString(),
    minExpectedAmount: minExpectedAmount.toString(),
    start: subscription.current_period_start,
    end: subscription.current_period_end,
    currency,
    quoteInfo,
  };
}

export async function getPastInvoicesAmount(subscriptionId: string, aggregationRule: 'sum' | 'avg' | 'max' = 'max') {
  const subscription = await Subscription.findByPk(subscriptionId);
  if (!subscription) {
    throw new Error('Subscription not found');
  }

  const currency = await PaymentCurrency.findByPk(subscription.currency_id);

  // 获取所有的账单周期
  const pastInvoices = await Invoice.findAll({
    where: {
      subscription_id: subscriptionId,
      currency_id: subscription.currency_id,
      billing_reason: [
        'subscription_create',
        'subscription_cycle',
        'subscription_update',
        'subscription_recover',
        'subscription_threshold',
        'subscription_cancel',
        'subscription',
      ],
    },
    order: [['created_at', 'DESC']],
  });

  const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, subscription.current_period_end);
  let start = setup.period.start - setup.cycle / 1000;
  let end = setup.period.end - setup.cycle / 1000;
  const createdAt = dayjs(subscription.created_at).unix();
  const amountsByPeriod: { [key: string]: BN } = {};

  while (start < end && start >= createdAt) {
    const periodKey = `${start}-${end}`;
    // eslint-disable-next-line @typescript-eslint/no-loop-func
    const invoices = pastInvoices.filter((x) => x.period_start >= start && x.period_end <= end);
    const amount = invoices.reduce((acc, x) => acc.add(new BN(x.total)), new BN(0));
    amountsByPeriod[periodKey] = amount;
    start -= setup.cycle / 1000;
    end -= setup.cycle / 1000;
  }

  let result = new BN(0);
  switch (aggregationRule) {
    case 'sum':
      result = Object.values(amountsByPeriod).reduce((acc, curr) => acc.add(curr), new BN(0));
      break;
    case 'avg':
      result =
        Object.values(amountsByPeriod).length > 0
          ? Object.values(amountsByPeriod)
              .reduce((acc, curr) => acc.add(curr), new BN(0))
              .div(new BN(Object.values(amountsByPeriod).length))
          : new BN(0);
      break;
    case 'max':
      result =
        Object.values(amountsByPeriod).length > 0
          ? Object.values(amountsByPeriod).reduce((acc, curr) => (acc.gt(curr) ? acc : curr), new BN(0))
          : new BN(0);
      break;
    default:
      throw new Error('Invalid aggregation rule');
  }

  return {
    amount: result.toString(),
    currency,
  };
}

export async function finalizeSubscriptionUpdate({
  subscription,
  customer,
  invoice,
  paymentCurrency,
  appliedCredit,
  newCredit,
  addedItems,
  updatedItems,
  deletedItems,
  updates,
}: {
  subscription: Subscription;
  customer: Customer;
  invoice: Invoice | null;
  paymentCurrency: PaymentCurrency;
  appliedCredit: string;
  newCredit: string;
  addedItems: SubscriptionUpdateItem[];
  updatedItems: SubscriptionUpdateItem[];
  deletedItems: SubscriptionUpdateItem[];
  updates: any;
}) {
  if (isEmpty(updates)) {
    logger.info('subscription update aborted', { subscription: subscription.id, updates });
    return;
  }

  logger.info('subscription update finalized', { subscription: subscription.id, updates });
  await subscription.update({ ...updates, pending_update: null });

  // update subscription items
  for (const item of addedItems) {
    await SubscriptionItem.create({
      price_id: item.price_id as string,
      quantity: item.quantity as number,
      livemode: subscription.livemode,
      subscription_id: subscription.id,
      metadata: {},
    });
    logger.info('subscription item added on update finalize', { subscription: subscription.id, item: item.id });
  }
  for (const item of updatedItems) {
    await SubscriptionItem.update(pick(item, ['quantity', 'metadata', 'billing_thresholds']), {
      where: { id: item.id },
    });
    logger.info('subscription item updated on update finalize', { subscription: subscription.id, item: item.id });
  }
  for (const item of deletedItems) {
    if (item.clear_usage) {
      await UsageRecord.destroy({ where: { subscription_item_id: item.id } });
      logger.info('subscription item usage cleared on update finalize', {
        subscription: subscription.id,
        item: item.id,
      });
    }
    await SubscriptionItem.destroy({ where: { id: item.id } });
    logger.info('subscription item deleted on update finalize', { subscription: subscription.id, item: item.id });
  }

  // update customer credits
  if (appliedCredit !== '0') {
    const creditResult = await customer.decreaseTokenBalance(paymentCurrency.id, appliedCredit);
    if (invoice) {
      await invoice.update({
        starting_token_balance: creditResult.starting,
        ending_token_balance: creditResult.ending,
      });
      logger.info('customer credit applied to invoice after proration', {
        subscription: subscription.id,
        appliedCredit,
        creditResult,
      });
    }
  }
  if (newCredit !== '0') {
    const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
    if (invoice) {
      await invoice.update({
        starting_token_balance: creditResult.starting,
        ending_token_balance: creditResult.ending,
      });
      logger.info('subscription proration credit applied to customer', {
        subscription: subscription.id,
        newCredit,
        creditResult,
      });
    }
  }

  // lock for next update
  const releaseAt = subscription.current_period_end;
  await Lock.acquire(`${subscription.id}-change-plan`, releaseAt);
  logger.info('subscription plan change lock acquired on finalize', { subscription: subscription.id, releaseAt });
}

export async function finalizeStripeSubscriptionUpdate({
  subscription,
  items,
  updates,
}: {
  subscription: Subscription;
  items: any[];
  updates: any;
}) {
  if (isEmpty(updates)) {
    logger.info('subscription update aborted', { subscription: subscription.id, updates });
    return;
  }

  if (subscription.pending_update) {
    // handle subscription item remove
    // this must be done before update/create
    const localItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
    const deletedItems = localItems.filter((x) => items.some((y) => y.id === x.id) === false).map((x) => x.id);
    if (deletedItems.length) {
      await UsageRecord.destroy({ where: { subscription_item_id: deletedItems } });
      logger.info('subscription item usage cleared on update', { subscription: subscription.id, deletedItems });
      await SubscriptionItem.destroy({ where: { id: deletedItems } });
      logger.info('subscription item deleted on update', { subscription: subscription.id, deletedItems });
    }

    // handle subscription item update/create
    for (const item of items) {
      if (item.id) {
        // remote item is associated with local item
        await SubscriptionItem.update(pick(item, ['quantity', 'billing_thresholds']), { where: { id: item.id } });
        logger.info('subscription item synced on update', { subscription: subscription.id, item });
      } else {
        // remote item not associated with local item
        const created = await SubscriptionItem.create({
          price_id: item.price_id as string,
          quantity: (item.quantity || 1) as number,
          livemode: subscription.livemode,
          subscription_id: subscription.id,
          metadata: {
            stripe_id: item.stripe_id,
            stripe_subscription_id: item.stripe_subscription_id,
          },
        });
        logger.info('subscription item mirrored on update', { subscription: subscription.id, item });
        const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
        const client = method!.getStripeClient();
        const result = await client.subscriptionItems.update(item.stripe_id, {
          metadata: { appPid: env.appPid, id: created.id },
        });
        logger.info('subscription item related on mirror', { subscription: subscription.id, item, result });
      }
    }

    const releaseAt = subscription.current_period_end;
    await Lock.acquire(`${subscription.id}-change-plan`, releaseAt);
    logger.info('subscription plan change lock acquired on finalize', { subscription: subscription.id, releaseAt });

    createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(console.error);
  }

  logger.info('subscription update finalized', { subscription: subscription.id, updates, items });
  await subscription.update({ ...updates, pending_update: null });
}

export async function onSubscriptionUpdateConnected(subscriptionId: string) {
  const subscription = await Subscription.findByPk(subscriptionId);
  if (!subscription) {
    return;
  }

  const now = dayjs().unix();
  const pending = subscription.pending_update;
  logger.info('subscription update connected', { subscription: subscription.id, pending });
  if (pending?.updates && pending?.expires_at && pending.expires_at >= now && pending.updates?.latest_invoice_id) {
    const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
    if (!paymentCurrency) {
      return;
    }

    const customer = await Customer.findByPk(subscription.customer_id);
    if (!customer) {
      return;
    }

    const invoice = await Invoice.findByPk(pending.updates.latest_invoice_id);
    if (!invoice) {
      return;
    }

    await finalizeSubscriptionUpdate({
      subscription,
      customer,
      invoice,
      paymentCurrency,
      appliedCredit: pending.appliedCredit || '0',
      newCredit: pending.newCredit || '0',
      addedItems: pending.addedItems || [],
      deletedItems: pending.deletedItems || [],
      updatedItems: pending.updatedItems || [],
      updates: pending.updates,
    });
  }
}

export async function getRemainingStakes(subscriptionIds: string[], subscriptionInitStakes: { [key: string]: string }) {
  if (!subscriptionIds || subscriptionIds.length === 0) {
    return '0';
  }
  let total = new BN('0');
  await Promise.all(
    subscriptionIds.map(async (subscriptionId) => {
      const refund = await Refund.findOne({
        where: {
          subscription_id: subscriptionId,
          status: {
            [Op.not]: 'canceled',
          },
          type: 'stake_return',
        },
      });
      if (!refund) {
        // this subscription not return stake
        total = total.add(new BN(subscriptionInitStakes[subscriptionId] || '0'));
      }
    })
  );
  return total.toString();
}

export async function getSubscriptionStakeSlashSetup(
  subscription: Subscription,
  address: string,
  paymentMethod: PaymentMethod,
  paymentCurrencyId?: string
) {
  const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
  const result = await getSubscriptionRemainingStakeSetup(
    subscription,
    address,
    paymentMethod,
    'slash',
    paymentCurrencyId
  );
  return {
    ...result,
    lastInvoice,
  };
}

export async function getSubscriptionStakeReturnSetup(
  subscription: Subscription,
  address: string,
  paymentMethod: PaymentMethod,
  paymentCurrencyId?: string
) {
  const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
  const result = await getSubscriptionRemainingStakeSetup(
    subscription,
    address,
    paymentMethod,
    'return',
    paymentCurrencyId
  );
  return {
    ...result,
    lastInvoice,
  };
}

export async function getSubscriptionRemainingStakeSetup(
  subscription: Subscription,
  address: string,
  paymentMethod: PaymentMethod,
  action: 'return' | 'slash' = 'return',
  paymentCurrencyId?: string
) {
  const currency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
  if (!currency) {
    return {
      total: '0',
      return_amount: '0',
      sender: '',
    };
  }

  const client = paymentMethod.getOcapClient();
  const { state } = await client.getStakeState({ address });
  if (!state) {
    return {
      total: '0',
      return_amount: '0',
      sender: '',
    };
  }

  let total = new BN(state.tokens.find((x: any) => x.address === currency.contract)?.value || '0');
  if (action === 'slash') {
    // add revoked tokens to total
    const revoked = state.revokedTokens?.find((x: any) => x.address === currency.contract);
    total = total.add(new BN(revoked?.value || '0'));
  }
  const [summary] = await Invoice.getUncollectibleAmount({
    subscriptionId: subscription.id,
    currencyId: currency.id,
    customerId: subscription.customer_id,
  });
  const subscriptionInitStakes = JSON.parse(state.data?.value || '{}');
  const initStake = subscriptionInitStakes[subscription.id];
  const uncollectibleAmountBN = new BN(summary?.[currency.id] || '0');
  if (state.nonce) {
    const returnStake = total.sub(uncollectibleAmountBN);
    return {
      total: total.toString(),
      return_amount: returnStake.lt(new BN(0)) ? '0' : returnStake.toString(),
      sender: state.sender,
    };
  }
  const getReturnState = async () => {
    const initStakeBN = new BN(initStake);
    const otherSubscriptionIds = Object.keys(subscriptionInitStakes).filter(
      (k) => k !== 'appId' && k !== subscription.id
    );
    const remainingStakes = await getRemainingStakes(otherSubscriptionIds, subscriptionInitStakes);
    const actualStakeBN = new BN(total).sub(new BN(remainingStakes));
    const minStakeBN = initStakeBN.lt(actualStakeBN) ? initStakeBN : actualStakeBN;
    return minStakeBN.sub(uncollectibleAmountBN);
  };
  const returnStake = await getReturnState();
  return {
    total: initStake,
    return_amount: returnStake.lt(new BN(0)) ? '0' : returnStake.toString(),
    sender: state.sender,
  };
}

export async function checkRemainingStake(
  paymentMethod: PaymentMethod,
  paymentCurrency: PaymentCurrency,
  address: string,
  amount: string
): Promise<{ enough: boolean; staked: any; revoked: any }> {
  if (paymentMethod?.type !== 'arcblock') {
    return {
      enough: false,
      staked: '0',
      revoked: '0',
    };
  }

  const client = paymentMethod.getOcapClient();
  const { state } = await client.getStakeState({ address });
  if (!state) {
    logger.warn('getStakeState failed in checkRemainingStake', { address });
    return {
      enough: false,
      staked: '0',
      revoked: '0',
    };
  }

  const staked = state.tokens?.find((x: any) => x.address === paymentCurrency.contract);
  const revoked = state.revokedTokens?.find((x: any) => x.address === paymentCurrency.contract);
  let total = new BN(0);
  if (staked) {
    total = total.add(new BN(staked?.value || '0'));
  }
  if (revoked) {
    total = total.add(new BN(revoked?.value || '0'));
  }

  return {
    enough: total.gte(new BN(amount || '0')),
    staked,
    revoked,
  };
}

// trialing can be customized with currency_id list
export function getSubscriptionTrialSetup(data: Partial<SubscriptionData>, currencyId: string) {
  let trialInDays = Number(data?.trial_period_days || 0);
  let trialEnd = Number(data?.trial_end || 0);
  const trialCurrencyIds = (data?.trial_currency || '').split(',').map(trim).filter(Boolean);
  if (trialCurrencyIds.length > 0 && trialCurrencyIds.includes(currencyId) === false) {
    trialEnd = 0;
    trialInDays = 0;
  }

  return {
    trialInDays,
    trialEnd,
  };
}

export async function getSubscriptionStakeAddress(
  subscription: Subscription,
  customerDid: string,
  paymentMethod?: PaymentMethod
) {
  // If stored address exists, use it
  const storedAddress = subscription.payment_details?.arcblock?.staking?.address;
  if (storedAddress) {
    return storedAddress;
  }

  // Use migration-aware fallback if paymentMethod is available
  if (paymentMethod?.type === 'arcblock') {
    const client = paymentMethod.getOcapClient();
    const stakingResult = await getStakingAddressWithFallback({
      userDid: customerDid,
      nonce: subscription.id,
      client,
    });
    if (stakingResult) {
      return stakingResult.address;
    }
  }

  // Fallback to direct calculation
  return getCustomerStakeAddress(customerDid, subscription.id);
}

export async function getSubscriptionStakeCancellation(
  subscription: Subscription,
  paymentMethod: PaymentMethod,
  paymentCurrency: PaymentCurrency
): Promise<{
  stakeReturn: boolean;
  stakeSlash: boolean;
  stakeEnough: boolean;
  hasStake: boolean;
}> {
  const cancellation = {
    stakeReturn: subscription.cancelation_details?.return_stake === true,
    stakeSlash: subscription.cancelation_details?.slash_stake === true,
    stakeEnough: false,
    hasStake: !!subscription?.payment_details?.arcblock?.staking?.tx_hash,
  };
  const customerCancelRequest = subscription.cancelation_details?.reason === 'cancellation_requested';
  if (customerCancelRequest) {
    // cancel request from customer
    const address = subscription?.payment_details?.arcblock?.staking?.address;
    let stakeEnough;
    if (address && paymentMethod) {
      const stakeReturnResult = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
      stakeEnough = await checkRemainingStake(paymentMethod, paymentCurrency, address, stakeReturnResult.return_amount);
      cancellation.stakeEnough = stakeEnough?.enough;
    }
  }
  return cancellation;
}

export async function getSubscriptionStakeAmountSetup(
  subscription: Subscription,
  paymentMethod: PaymentMethod,
  stakingTxHash?: string
) {
  if (paymentMethod.type !== 'arcblock') {
    return null;
  }
  const txHash = stakingTxHash || subscription?.payment_details?.arcblock?.staking?.tx_hash;
  if (!txHash) {
    return null;
  }
  const client = paymentMethod.getOcapClient();
  const { info } = await client.getTx({ hash: txHash });
  if (!info) {
    return null;
  }
  // @ts-ignore
  const inputs = info?.tx?.itxJson?.inputs || [];
  if (!inputs || inputs.length === 0) {
    logger.info('getSubscriptionStakeAmountSetup failed, no inputs', { txHash, info });
    return null;
  }
  const amountRes: { [key: string]: string } = {};
  // calculate stake amount for each address
  inputs.forEach((input: any) => {
    const { tokens } = input;
    tokens.forEach((token: any) => {
      const { address, value } = token;
      if (amountRes[address]) {
        amountRes[address] = new BN(amountRes[address]).add(new BN(value)).toString();
      } else {
        amountRes[address] = new BN(value).toString();
      }
    });
  });
  logger.info('get subscription stake amount setup success', { txHash, amountRes });
  return amountRes;
}

// check if usage report is empty
export async function checkUsageReportEmpty(
  subscription: Subscription,
  usageReportStart: number,
  usageReportEnd: number
) {
  const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
  const expandedItems = await Price.expand(
    subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
    { product: true }
  );
  const meteredItems = expandedItems.filter((x: any) => x?.price?.recurring?.usage_type === 'metered');
  if (meteredItems.length === 0) {
    return false;
  }
  const usageReportEmpty = await Promise.all(
    meteredItems.map(async (x: any) => {
      const usageRecords = await UsageRecord.findAll({
        where: {
          subscription_item_id: x.id,
          billed: false,
          timestamp: {
            [Op.gt]: usageReportStart,
            [Op.lte]: usageReportEnd,
          },
        },
      });
      return usageRecords.length === 0;
    })
  );
  return usageReportEmpty.every(Boolean);
}

export function getSubscriptionPaymentAddress(subscription: Subscription, paymentMethodType: string) {
  return (
    // @ts-ignore
    subscription.payment_details?.[paymentMethodType]?.payer ||
    // @ts-ignore
    subscription.payment_settings?.payment_method_options?.[paymentMethodType]?.payer
  );
}
export async function getPaymentAmountForCycleSubscription(
  subscription: Subscription,
  paymentCurrency: PaymentCurrency
) {
  const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
  if (subscriptionItems.length === 0) {
    logger.info('subscription items not found in getPaymentAmountForCycleSubscription', {
      subscription: subscription.id,
    });
    return 0;
  }
  let expandedItems = await Price.expand(
    subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
    { product: true }
  );
  if (expandedItems.length === 0) {
    logger.info('expanded items not found in getPaymentAmountForCycleSubscription', {
      subscription: subscription.id,
    });
    return 0;
  }
  const previousPeriodEnd =
    subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
  const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
  // get usage summaries for this billing cycle
  expandedItems = await Promise.all(
    expandedItems.map(async (x: any) => {
      // For metered billing, we need to get usage summary for this billing cycle
      // @link https://stripe.com/docs/products-prices/pricing-models#usage-types
      if (x.price.recurring?.usage_type === 'metered') {
        const rawQuantity = await UsageRecord.getSummary({
          id: x.id,
          start: setup.period.start - setup.cycle / 1000,
          end: setup.period.end - setup.cycle / 1000,
          method: x.price.recurring?.aggregate_usage,
          dryRun: true,
        });
        x.quantity = x.price.transformQuantity(rawQuantity);
        // record raw quantity in metadata
        x.metadata = x.metadata || {};
        x.metadata.quantity = rawQuantity;
      }
      return x;
    })
  );
  if (expandedItems.length > 0) {
    const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
    return +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal);
  }
  return 0;
}

export async function getEstimatedPaymentAmountForCycleSubscription(
  subscription: Subscription,
  paymentCurrency: PaymentCurrency
): Promise<{ amount: number; estimatedByRate: boolean }> {
  const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
  if (subscriptionItems.length === 0) {
    logger.info('subscription items not found in getEstimatedPaymentAmountForCycleSubscription', {
      subscription: subscription.id,
    });
    return { amount: 0, estimatedByRate: false };
  }
  let expandedItems = await Price.expand(
    subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
    { product: true }
  );
  if (expandedItems.length === 0) {
    logger.info('expanded items not found in getEstimatedPaymentAmountForCycleSubscription', {
      subscription: subscription.id,
    });
    return { amount: 0, estimatedByRate: false };
  }
  const previousPeriodEnd =
    subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
  const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
  expandedItems = await Promise.all(
    expandedItems.map(async (x: any) => {
      if (x.price.recurring?.usage_type === 'metered') {
        const rawQuantity = await UsageRecord.getSummary({
          id: x.id,
          start: setup.period.start - setup.cycle / 1000,
          end: setup.period.end - setup.cycle / 1000,
          method: x.price.recurring?.aggregate_usage,
          dryRun: true,
        });
        x.quantity = x.price.transformQuantity(rawQuantity);
        x.metadata = x.metadata || {};
        x.metadata.quantity = rawQuantity;
      }
      return x;
    })
  );
  if (expandedItems.length === 0) {
    return { amount: 0, estimatedByRate: false };
  }

  let estimatedByRate = false;
  const hasDynamicItems = expandedItems.some((item: any) => item.price?.pricing_type === 'dynamic');
  if (hasDynamicItems) {
    try {
      const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
      if (!paymentMethod) {
        throw new Error(`PaymentMethod not found in ${subscription.id}`);
      }
      const exchangeRateService = getExchangeRateService();
      const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
      const rateResult = await exchangeRateService.getRate(rateSymbol);
      const USD_DECIMALS = 8;
      const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);

      expandedItems = expandedItems.map((item: any) => {
        const { price } = item;
        if (price?.pricing_type !== 'dynamic') {
          return item;
        }
        if (!price.base_amount) {
          logger.warn('Dynamic price missing base_amount for estimate', {
            subscriptionId: subscription.id,
            priceId: price.id,
          });
          return item;
        }

        const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
        const quantityBN = new BN(item.quantity || 0);
        const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
        const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(paymentCurrency.decimal)));
        const quotedAmountRaw = numerator.add(rateBN).sub(new BN(1)).div(rateBN);
        const quotedAmount = limitTokenPrecision(quotedAmountRaw, paymentCurrency.decimal, 10);
        estimatedByRate = true;
        return {
          ...item,
          custom_amount: quotedAmount.toString(),
          quoted_amount: quotedAmount.toString(),
        };
      });
    } catch (error: any) {
      logger.warn('Failed to estimate dynamic pricing amount for subscription', {
        subscriptionId: subscription.id,
        error: error?.message || error,
      });
    }
  }

  const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
  return { amount: +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal), estimatedByRate };
}

// check if subscription overdraft protection is enabled
export async function isSubscriptionOverdraftProtectionEnabled(subscription: Subscription, paymentCurrencyId?: string) {
  try {
    const paymentCurrency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
    if (!paymentCurrency) {
      throw new Error(`PaymentCurrency not found in ${subscription.id}`);
    }
    const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
    if (!paymentMethod) {
      throw new Error(`PaymentMethod not found in ${subscription.id}`);
    }

    // Overdraft protection is only meaningful for on-chain (arcblock) payment
    // methods where users stake tokens. IAP / Stripe channels have no notion of
    // staking; return disabled-default silently rather than throwing into the
    // outer catch (which would spam the error log on every google_play /
    // app_store / stripe subscription event).
    if (paymentMethod.type !== 'arcblock') {
      return {
        enabled: false,
        remaining: '0',
        used: '0',
        shouldPay: '0',
        unused: '0',
        revokedStake: '0',
      };
    }
    if (!subscription.overdraft_protection) {
      return {
        enabled: false,
        remaining: '0',
        used: '0',
        shouldPay: '0',
        unused: '0',
        revokedStake: '0',
      };
    }
    const client = paymentMethod.getOcapClient();
    const address = subscription?.overdraft_protection?.payment_details?.arcblock?.staking?.address;
    if (!address) {
      logger.info('Seems you have no staking for overdraft protection', { subscription: subscription.id });
      return {
        enabled: false,
        remaining: '0',
        used: '0',
        shouldPay: '0',
        unused: '0',
        revokedStake: '0',
      };
    }
    const { state } = await client.getStakeState({ address });
    if (!state) {
      throw new Error(`Stake state not found in ${subscription.id}`);
    }
    const data = JSON.parse(state.data?.value || '{}');
    if (!data[subscription.id] && !state.nonce) {
      throw new Error(`No staking for subscription: ${subscription.id}, address: ${address}`);
    }

    // calculate remaining stake, need to subtract refunding amount
    let remainingStake = new BN(state.tokens.find((x: any) => x.address === paymentCurrency.contract)?.value || '0');
    const revokedStake = new BN(
      state.revokedTokens?.find((x: any) => x.address === paymentCurrency.contract)?.value || '0'
    );

    const refunds = await Refund.findAll({
      where: {
        subscription_id: subscription.id,
        type: 'stake_return',
        status: {
          [Op.notIn]: ['canceled', 'succeeded'],
        },
        currency_id: paymentCurrency.id,
      },
      include: [{ model: Invoice, as: 'invoice' }],
    });
    const refundAmount = refunds
      .filter((x: any) => x.invoice?.billing_reason === 'stake_overdraft_protection')
      .reduce((acc, x) => acc.add(new BN(x.amount)), new BN(0));
    remainingStake = remainingStake.sub(refundAmount);

    const invoices = await Invoice.findAll({
      where: {
        subscription_id: subscription.id,
        status: ['open', 'uncollectible'],
        currency_id: paymentCurrency.id,
      },
    });
    let usedAmount = new BN(0);
    let dueAmount = new BN(0);
    invoices.forEach((invoice) => {
      usedAmount = usedAmount.add(new BN(invoice.amount_remaining));
      if (invoice.billing_reason !== 'overdraft_protection') {
        dueAmount = dueAmount.add(new BN(invoice.amount_remaining));
      }
    });
    return {
      enabled: subscription.overdraft_protection.enabled && usedAmount.lte(remainingStake),
      remaining: remainingStake.toString(),
      used: usedAmount.toString(),
      shouldPay: dueAmount.toString(),
      revoked: revokedStake.toString(),
      unused: remainingStake.gte(usedAmount) ? remainingStake.sub(usedAmount).toString() : '0',
    };
  } catch (error) {
    logger.error('error in isSubscriptionOverdraftProtectionEnabled', { error });
    return {
      enabled: false,
      remaining: '0',
      used: '0',
      unused: '0',
      shouldPay: '0',
      revokedStake: '0',
    };
  }
}

// return remaining overdraft protection stake to customer
export async function returnOverdraftProtectionStake(
  subscription: Subscription,
  paymentCurrencyId?: string,
  refundReason?: string
) {
  // return remaining stake to customer
  logger.info('returnOverdraftProtectionStake', { subscription: subscription.id, paymentCurrencyId });
  try {
    await slashOverdraftProtectionStake(subscription, paymentCurrencyId);
    const { unused } = await isSubscriptionOverdraftProtectionEnabled(subscription, paymentCurrencyId);
    if (unused !== '0') {
      // return unused stake to customer
      const address = subscription?.overdraft_protection?.payment_details?.arcblock?.staking?.address;
      if (!address) {
        logger.info('Stake return skipped due to missing staking address', { subscription: subscription.id });
        return;
      }
      const invoice = await Invoice.findOne({
        where: {
          subscription_id: subscription.id,
          billing_reason: 'stake_overdraft_protection',
          currency_id: paymentCurrencyId || subscription.currency_id,
          status: 'paid',
        },
        order: [['created_at', 'DESC']],
      });
      const item = await Refund.create({
        type: 'stake_return',
        amount: unused,
        description: 'stake_return_for_subscription_guard',
        status: 'pending',
        reason: refundReason || 'requested_by_admin',
        subscription_id: subscription.id,
        currency_id: paymentCurrencyId || subscription.currency_id,
        livemode: subscription.livemode,
        customer_id: subscription.customer_id,
        invoice_id: invoice?.id,
        payment_intent_id: invoice?.payment_intent_id || '',
        payment_method_id: subscription.overdraft_protection?.payment_method_id || '',
        starting_balance: unused,
        ending_balance: '0',
        attempt_count: 0,
        attempted: false,
        next_attempt: 0,
        last_attempt_error: null,
        starting_token_balance: {},
        ending_token_balance: {},
        payment_details: {
          // @ts-ignore
          arcblock: {
            receiver:
              subscription?.overdraft_protection?.payment_details?.arcblock?.payer ||
              getSubscriptionPaymentAddress(subscription, 'arcblock'),
            staking: {
              address: address || '',
              tx_hash: '',
            },
          },
        },
      });
      logger.info('returnOverdraftProtectionStake Refund created', { item });
    }
  } catch (error) {
    logger.error('returnOverdraftProtectionStake failed', { error });
    throw error;
  }
}

// slash overdraft protection stake
export async function slashOverdraftProtectionStake(subscription: Subscription, paymentCurrencyId?: string) {
  const lock = getLock(
    `slash_overdraft_protection_stake_${subscription.id}-${paymentCurrencyId || subscription.currency_id}`
  );
  logger.info('slashOverdraftProtectionStake', { subscription: subscription.id, paymentCurrencyId });
  try {
    await lock.acquire();
    if (!subscription.overdraft_protection) {
      logger.info('slashOverdraftProtectionStake skipped due to missing overdraft protection', {
        subscription: subscription.id,
      });
      return;
    }

    const paymentCurrency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
    if (!paymentCurrency) {
      throw new Error(`PaymentCurrency not found in ${subscription.id}`);
    }

    const { used, remaining, revoked } = await isSubscriptionOverdraftProtectionEnabled(
      subscription,
      paymentCurrency.id
    );
    const remainingStake = new BN(remaining).add(new BN(revoked)).toString();
    if (used === '0') {
      logger.info('slashOverdraftProtectionStake skipped due to no unpaid invoices', {
        subscription: subscription.id,
        paymentCurrencyId: paymentCurrency.id,
      });
      return;
    }

    if (remainingStake === '0') {
      logger.info('slashOverdraftProtectionStake skipped due to no remaining stake', {
        subscription: subscription.id,
        paymentCurrencyId: paymentCurrency.id,
      });
      return;
    }

    const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
    if (!paymentMethod || paymentMethod.type !== 'arcblock') {
      throw new Error(`PaymentMethod type not supported in ${subscription.id}`);
    }

    const address = subscription?.overdraft_protection?.payment_details?.arcblock?.staking?.address;

    if (!address) {
      // no staking address
      logger.info('Stake slashing skipped due to missing staking address', { subscription: subscription.id });
      return;
    }

    const client = paymentMethod.getOcapClient();
    const { state } = await client.getStakeState({ address });

    if (!state || !state.data?.value) {
      throw new Error(
        `Stake slashing aborted due to missing staking state for subscription: ${subscription.id}, address: ${address}`
      );
    }

    const data = JSON.parse(state.data.value || '{}');
    if (!data[subscription.id] && !state.nonce) {
      throw new Error(
        `Stake slashing aborted because no staking for subscription: ${subscription.id}, address: ${address}`
      );
    }

    const invoices = await Invoice.findAll({
      where: {
        subscription_id: subscription.id,
        status: ['open', 'uncollectible'],
        currency_id: paymentCurrency.id,
      },
      order: [['created_at', 'ASC']],
    });

    let slashAmount = new BN(0);
    const slashInvoices: Invoice[] = [];
    for (const invoice of invoices) {
      if (!invoice.payment_intent_id) {
        await emitAsync(
          'invoice.queued',
          invoice.id,
          { invoiceId: invoice.id, retryOnError: false, justCreate: true },
          { sync: true }
        );
        await invoice.reload();
      }

      const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
      if (paymentIntent) {
        const isAlreadyPaid = paymentIntent.status === 'succeeded' && invoice.status === 'paid';

        if (!isAlreadyPaid) {
          const tmp = slashAmount.add(new BN(invoice.amount_remaining));
          if (tmp.lte(new BN(remainingStake))) {
            slashAmount = tmp;
            slashInvoices.push(invoice);
          } else {
            break;
          }
        } else {
          logger.info('PaymentIntent and Invoice already updated', {
            subscription: subscription.id,
            paymentIntent: paymentIntent.id,
            invoice: invoice.id,
          });
        }
      }
    }

    const slashedInvoiceIds: string[] = [];
    const processInvoice = async (invoice: Invoice): Promise<void> => {
      const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
      if (!paymentIntent || (paymentIntent.status === 'succeeded' && invoice.status === 'paid')) return;

      try {
        const stakeEnough = await checkRemainingStake(
          paymentMethod,
          paymentCurrency,
          address,
          invoice.amount_remaining
        );
        if (!stakeEnough.enough) {
          throw new Error(`Stake slashing aborted because no enough staking for invoice: ${invoice.id}`);
        }
        // do the slash
        const signed = await client.signSlashStakeTx({
          tx: {
            itx: {
              address,
              outputs: [
                {
                  owner: wallet.address,
                  tokens: [{ address: paymentCurrency.contract, value: invoice.amount_remaining }],
                },
              ],
              message: 'overdraft_exceeded',
              data: {
                typeUrl: 'json',
                // @ts-ignore
                value: {
                  appId: wallet.address,
                  reason: 'overdraft_exceeded',
                  subscriptionId: subscription.id,
                  invoiceId: invoice.id,
                  paymentIntentId: paymentIntent.id,
                },
              },
            },
          },
          wallet,
        });
        // @ts-ignore
        const { buffer } = await client.encodeSlashStakeTx({ tx: signed });
        // @ts-ignore
        const txHash = await client.sendSlashStakeTx({ tx: signed, wallet }, await getGasPayerExtra(buffer));

        await paymentIntent.update({
          status: 'succeeded',
          amount_received: invoice.amount_remaining,
          capture_method: 'manual',
          last_payment_error: null,
          payment_details: {
            arcblock: {
              tx_hash: txHash,
              payer:
                subscription.overdraft_protection?.payment_details?.arcblock?.payer ||
                getSubscriptionPaymentAddress(subscription, 'arcblock'),
              type: 'slash',
            },
          },
        });
        logger.info('PaymentIntent updated after stake slash', {
          subscription: subscription.id,
          paymentIntent: paymentIntent.id,
          status: 'succeeded',
        });

        await invoice.update({
          status: 'paid',
          paid: true,
          amount_paid: invoice.amount_remaining,
          amount_remaining: '0',
          attempted: true,
          attempt_count: invoice.attempt_count + 1,
          status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
        });
        logger.info('Invoice updated after stake slash', {
          subscription: subscription.id,
          invoice: invoice.id,
          status: 'paid',
        });
        slashedInvoiceIds.push(invoice.id);
      } catch (updateError) {
        logger.error('stake slash failed', {
          subscription: subscription.id,
          invoice: invoice.id,
          error: updateError,
        });
        throw updateError;
      }
    };

    await Promise.all(slashInvoices.map(processInvoice));
    logger.info(`${slashedInvoiceIds.length} invoices updated after stake slash`, {
      subscription: subscription.id,
      invoices: slashedInvoiceIds,
      slashAmount: slashAmount.toString(),
    });
  } catch (error) {
    logger.error('Error in slashOverdraftProtectionStake', { error });
    throw error;
  } finally {
    lock.release();
  }
}

export async function getSubscriptionUnpaidInvoicesCount(subscription: Subscription) {
  try {
    const count = await Invoice.count({
      where: {
        subscription_id: subscription.id,
        status: ['open', 'uncollectible'],
        billing_reason: {
          [Op.notIn]: ['overdraft_protection'],
        },
      },
    });
    return count;
  } catch (error) {
    logger.error('getSubscriptionUnpaidInvoicesCount failed', { error });
    return 0;
  }
}

/**
 * Calculate recommended recharge amount for subscription cycles
 * @param subscriptions List of subscriptions
 * @param currencyId Currency ID
 * @returns {amount: string, cycle: number, desc: string, interval: string} Recommended amount and cycle info
 */
export function calculateRecommendedRechargeAmount(subscriptions: any[], currencyId: string) {
  if (!subscriptions || subscriptions.length === 0) {
    return { amount: '0', cycle: 0, desc: 'no subscription', interval: '' };
  }

  type IntervalType = 'hour' | 'day' | 'month' | 'year';

  // Group subscriptions by billing interval
  const intervalSubscriptions: Record<IntervalType, any[]> = {
    hour: [],
    day: [],
    month: [],
    year: [],
  };

  // Classify subscriptions by interval
  subscriptions.forEach((subscription) => {
    if (!subscription.items?.length) return;

    subscription.items.forEach((item: any) => {
      const { price } = item;
      if (price?.type === 'recurring' && price.recurring?.interval) {
        const interval = price.recurring.interval as IntervalType;
        if (Object.keys(intervalSubscriptions).includes(interval)) {
          intervalSubscriptions[interval].push({ subscription, item, price });
        }
      }
    });
  });

  const hasHourly = intervalSubscriptions.hour.length > 0;
  const hasDaily = intervalSubscriptions.day.length > 0;
  const hasMonthly = intervalSubscriptions.month.length > 0;
  const hasYearly = intervalSubscriptions.year.length > 0;

  // Determine recommended cycle based on subscription intervals
  let recommendedInterval = '';
  let cycleInSeconds = 0;
  let desc = '';

  if (hasMonthly) {
    recommendedInterval = 'month';
    cycleInSeconds = 30 * 24 * 60 * 60;
    desc = 'monthly';
  } else if ((hasHourly || hasDaily) && !hasYearly) {
    recommendedInterval = 'week';
    cycleInSeconds = 7 * 24 * 60 * 60;
    desc = 'weekly';
  } else if ((hasDaily || hasHourly) && hasYearly) {
    recommendedInterval = 'month';
    cycleInSeconds = 30 * 24 * 60 * 60;
    desc = 'monthly';
  } else if (hasYearly) {
    recommendedInterval = 'year';
    cycleInSeconds = 365 * 24 * 60 * 60;
    desc = 'yearly';
  }

  let totalAmount = new BN(0);

  // Calculate amount based on recommended cycle
  subscriptions.forEach((subscription) => {
    if (!subscription.items?.length) return;

    subscription.items.forEach((item: any) => {
      const { price } = item;
      if (!price) return;

      const unitAmount = getPriceUintAmountByCurrency(price, currencyId);
      const quantity = item.quantity || 1;
      let itemAmount = new BN(unitAmount).mul(new BN(quantity));

      if (price.type === 'recurring' && price.recurring?.interval) {
        const { interval } = price.recurring;
        const intervalCount = price.recurring.interval_count || 1;

        // Apply period conversion calculation
        if (recommendedInterval === 'month') {
          if (interval === 'hour') {
            itemAmount = itemAmount.mul(new BN(720)).div(new BN(intervalCount));
          } else if (interval === 'day') {
            itemAmount = itemAmount.mul(new BN(30)).div(new BN(intervalCount));
          } else if (interval === 'month') {
            itemAmount = itemAmount.div(new BN(intervalCount));
          } else if (interval === 'year') {
            itemAmount = itemAmount.div(new BN(12 * intervalCount));
          }
        } else if (recommendedInterval === 'week') {
          if (interval === 'hour') {
            itemAmount = itemAmount.mul(new BN(168)).div(new BN(intervalCount));
          } else if (interval === 'day') {
            itemAmount = itemAmount.mul(new BN(7)).div(new BN(intervalCount));
          }
        }

        totalAmount = totalAmount.add(itemAmount);
      } else if (price.type === 'one_time') {
        totalAmount = totalAmount.add(itemAmount);
      }
    });
  });

  return {
    amount: totalAmount.toString(),
    cycle: cycleInSeconds,
    desc,
    interval: recommendedInterval,
  };
}

export async function getMeterPriceIdsFromSubscription(subscription: Subscription): Promise<string[]> {
  const subscriptionItems = (await SubscriptionItem.findAll({
    where: { subscription_id: subscription.id },
    include: [{ model: Price, as: 'price' }],
  })) as (SubscriptionItem & { price: Price })[];

  const meterItems = subscriptionItems.filter((x) => x.price.recurring?.meter_id);
  return meterItems.map((m) => m.price.id);
}
