/* eslint-disable no-await-in-loop, no-continue --
   Sequential per-subscription processing is intentional: each iteration
   issues 1-2 D1 writes + an Apple/Google API call, and parallelizing would
   blast D1 with N concurrent transactions and trip per-method rate limits
   on Apple's App Store Server API. The `continue` guards bail early on
   rows that can't be processed (missing tenant, missing original txn,
   etc.) which is cleaner than nested if-else for a long per-row pipeline. */
// IAP reconcile cron — webhook backup.
//
// Webhooks are the fast path for App Store Server Notifications V2 and Google
// Play RTDN. They occasionally fail to deliver (Apple/Google outages, our 5xx
// during deploy, Pub/Sub backlog dropping old messages). When that happens the
// local Subscription row falls out of sync with the real subscription state at
// Apple/Google, and the user either keeps Pro after a refund or loses Pro at
// renewal.
//
// This module periodically pulls each active `app_store` / `google_play`
// subscription's authoritative state from the App Store Server API + Google
// Play Developer API and applies drift to the local row. Cron schedule is
// every 5 minutes by default; tuneable via `IAP_RECONCILE_CRON_TIME`.

import { Op } from 'sequelize';

import { createEvent } from '../libs/audit';
import logger from '../libs/logger';
import { PaymentMethod, Price, Subscription, SubscriptionItem } from '../store/models';
import { AppStoreClient, AppStoreTransactionPayload } from './app-store/client';
import { GooglePlayClient, GooglePlaySubscriptionPurchase } from './google-play/client';

/** Don't re-check subs that were updated by a webhook within the last 5 minutes. */
const RECENT_UPDATE_GUARD_MS = 5 * 60 * 1000;

/** Per-channel batch cap so a single cron tick can't stall on Apple/Google rate limits. */
const DEFAULT_BATCH_SIZE = Number(process.env.IAP_RECONCILE_BATCH_SIZE ?? '100');

type ReconcileStats = { checked: number; updated: number; errors: number };

const emptyStats = (): ReconcileStats => ({ checked: 0, updated: 0, errors: 0 });

/**
 * Single entry point used by the cron registry. Catches per-channel errors so
 * one channel's outage doesn't take down the other.
 */
export async function runIapReconcile(): Promise<{ app_store: ReconcileStats; google_play: ReconcileStats }> {
  const [appStore, googlePlay] = await Promise.all([
    reconcileAppStore().catch((err) => {
      logger.error('iap-reconcile: app_store channel failed', { error: err?.message });
      return emptyStats();
    }),
    reconcileGooglePlay().catch((err) => {
      logger.error('iap-reconcile: google_play channel failed', { error: err?.message });
      return emptyStats();
    }),
  ]);
  // Self-heal SubscriptionItem rows for any IAP sub whose Product mapping
  // became available AFTER purchase (typical: customer bought before admin
  // bound the SKU to a local Product, so ingest skipped SubscriptionItem
  // creation; once the binding exists, admin UI's product-name column and
  // anything else that walks subscription.items would otherwise stay blank
  // forever).
  await backfillMissingSubscriptionItems().catch((err) => {
    logger.error('iap-reconcile: subscription_items backfill failed', { error: err?.message });
  });
  logger.info('iap-reconcile: pass complete', { app_store: appStore, google_play: googlePlay });
  return { app_store: appStore, google_play: googlePlay };
}

/**
 * Backfill SubscriptionItem rows for IAP Subscriptions that lost out on the
 * happy-path create (Price was unmapped at ingest time, or the create call
 * crashed mid-flow). Idempotent — only inserts when both:
 *   - The sub has zero SubscriptionItem rows
 *   - We can resolve a Price via channel SKU → Price.metadata mapping
 *
 * Stripe-style schema: SKU binding lives on Price.metadata, not
 * Product.metadata, so we point the SubscriptionItem at the exact Price the
 * customer paid for (not the Product's default_price). That preserves the
 * monthly-vs-yearly distinction in the SubscriptionItem record.
 */
async function backfillMissingSubscriptionItems(): Promise<void> {
  const candidates = await Subscription.findAll({
    where: { channel: { [Op.in]: ['google_play', 'app_store'] } as any },
    limit: 500,
  });
  // Cache decoded PaymentMethod tenant identifiers (bundle_id / package_name)
  // so we don't re-decrypt settings for every sub. Most installations have a
  // small number of IAP PaymentMethods so this fits in a Map.
  const tenantCache = new Map<string, string | null>();
  const tenantFor = async (sub: Subscription): Promise<string | null> => {
    const pmId = (sub as any).default_payment_method_id as string | undefined;
    if (!pmId) return null;
    if (tenantCache.has(pmId)) return tenantCache.get(pmId)!;
    const pm = await PaymentMethod.findByPk(pmId);
    const settings = pm ? PaymentMethod.decryptSettings(pm.settings) : null;
    const tenant =
      sub.channel === 'google_play'
        ? (settings?.google_play?.package_name ?? null)
        : (settings?.app_store?.bundle_id ?? null);
    tenantCache.set(pmId, tenant);
    return tenant;
  };
  let inserted = 0;
  for (const sub of candidates) {
    const existing = await SubscriptionItem.count({ where: { subscription_id: sub.id } });
    if (existing > 0) continue;

    const pd: any = (sub as any).payment_details;
    const sku = sub.channel === 'google_play' ? pd?.google_play?.product_id : pd?.app_store?.product_id;
    if (!sku) continue;

    // Multi-tenant scoping: filter Price lookup by the originating app's
    // bundle_id (iOS) / package_name (Android). Without this, a sub from
    // App A with SKU "pro_monthly" could be backfilled with App B's Price
    // if App B also has a SKU "pro_monthly" — same SKU string lives in
    // independent App Store / Play Console namespaces.
    const tenant = await tenantFor(sub);
    if (!tenant) {
      logger.warn('iap-reconcile: no tenant id (bundleId/packageName) for sub, skipping backfill', {
        subscriptionId: sub.id,
        channel: sub.channel,
      });
      continue;
    }
    const skuKey = sub.channel === 'google_play' ? 'google_play_product_id' : 'app_store_product_id';
    const tenantKey = sub.channel === 'google_play' ? 'package_name' : 'bundle_id';
    const price = await Price.findOne({
      where: { [`metadata.${skuKey}`]: sku, [`metadata.${tenantKey}`]: tenant } as any,
    });
    if (!price) continue;

    await SubscriptionItem.create({
      subscription_id: sub.id,
      price_id: price.id,
      quantity: 1,
      livemode: sub.livemode,
      metadata: { backfilled_at: Math.floor(Date.now() / 1000), reason: 'reconcile_missing_item' },
    } as any);
    inserted += 1;
    logger.info('iap-reconcile: backfilled SubscriptionItem', {
      subscriptionId: sub.id,
      productId: price.product_id,
      priceId: price.id,
    });
  }
  if (inserted > 0) logger.info(`iap-reconcile: backfilled ${inserted} subscription_items`);
}

// ---------------------------------------------------------------------------
// App Store
// ---------------------------------------------------------------------------

export async function reconcileAppStore(batchSize = DEFAULT_BATCH_SIZE): Promise<ReconcileStats> {
  const stats = emptyStats();
  const methods = await PaymentMethod.findAll({ where: { type: 'app_store' } });
  if (methods.length === 0) return stats;

  const subs = await Subscription.findAll({
    where: {
      status: { [Op.in]: ['active', 'past_due', 'trialing'] },
      'payment_details.app_store.original_transaction_id': { [Op.ne]: null } as any,
      updated_at: { [Op.lt]: new Date(Date.now() - RECENT_UPDATE_GUARD_MS) },
    } as any,
    order: [['updated_at', 'ASC']],
    limit: batchSize,
  });

  for (const sub of subs) {
    stats.checked += 1;
    try {
      const originalTransactionId = sub.payment_details?.app_store?.original_transaction_id as string | undefined;
      if (!originalTransactionId) continue;

      const method = pickIapMethodForSub(methods, sub);
      if (!method) {
        logger.warn('iap-reconcile: no matching app_store PaymentMethod', { subscriptionId: sub.id });
        continue;
      }

      // Apple SDK throws when issuer/key creds aren't present. Skip cleanly.
      let client: AppStoreClient;
      try {
        client = method.getAppStoreClient();
      } catch (err: any) {
        logger.warn('iap-reconcile: cannot build app_store client', { subscriptionId: sub.id, error: err?.message });
        continue;
      }

      const transaction = await client.getSubscriptionStatus(originalTransactionId);
      if (!transaction) {
        logger.warn('iap-reconcile: app_store getSubscriptionStatus returned null', { originalTransactionId });
        continue;
      }
      const drifted = await applyAppStoreTransactionDrift(sub, transaction);
      if (drifted) stats.updated += 1;
    } catch (err: any) {
      stats.errors += 1;
      logger.error('iap-reconcile: app_store sub failed', { subscriptionId: sub.id, error: err?.message });
    }
  }
  return stats;
}

/**
 * Select the IAP PaymentMethod that actually funds this subscription. Match by
 * the bound `default_payment_method_id` — NOT the non-existent `payment_method_id`
 * (the previous bug, which always fell through to methods[0] and queried the
 * wrong app/credentials in multi-app/multi-env deployments — PR #1381 review P1).
 * Only default to the sole method when there is exactly one; never pick an
 * arbitrary method.
 */
export function pickIapMethodForSub(methods: PaymentMethod[], sub: Subscription): PaymentMethod | undefined {
  const bound = methods.find((m) => m.id === sub.default_payment_method_id);
  if (bound) return bound;
  return methods.length === 1 ? methods[0] : undefined;
}

/**
 * Compare Apple's authoritative `transaction` payload against our local row
 * and apply only the drifts that matter — period_end forward jump (renewed),
 * revocation (refunded), explicit expiry.
 *
 * Returns true if we wrote to the row.
 */
export async function applyAppStoreTransactionDrift(
  sub: Subscription,
  transaction: AppStoreTransactionPayload
): Promise<boolean> {
  const appleExpiresSec = transaction.expiresDate ? Math.floor(transaction.expiresDate / 1000) : undefined;
  const revoked = Boolean(transaction.revocationDate);

  if (revoked) {
    if (sub.status === 'canceled') return false;
    await sub.update({
      status: 'canceled',
      canceled_at: Math.floor((transaction.revocationDate ?? Date.now()) / 1000),
      cancelation_details: {
        reason: 'app_store_revoked',
        feedback: `reconcile-cron: detected revocationDate=${transaction.revocationDate}`,
      } as any,
      metadata: {
        ...(sub.metadata || {}),
        app_store_reconciled_at: Math.floor(Date.now() / 1000),
      },
    });
    createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
    return true;
  }

  // Renewal drift — Apple says we have more time than we record.
  if (appleExpiresSec && appleExpiresSec > sub.current_period_end + 60) {
    await sub.update({
      status: 'active',
      current_period_end: appleExpiresSec,
      payment_details: {
        ...(sub.payment_details || {}),
        app_store: {
          ...(sub.payment_details?.app_store || ({} as any)),
          expires_at: transaction.expiresDate ? Math.floor(transaction.expiresDate / 1000) : undefined,
        },
      },
      metadata: {
        ...(sub.metadata || {}),
        app_store_reconciled_at: Math.floor(Date.now() / 1000),
      },
    });
    createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
    return true;
  }

  // Past expiry — Apple says we're past period end but we still say active.
  const nowSec = Math.floor(Date.now() / 1000);
  if (appleExpiresSec && appleExpiresSec < nowSec - 60 && (sub.status === 'active' || sub.status === 'trialing')) {
    await sub.update({
      status: 'canceled',
      canceled_at: nowSec,
      cancelation_details: {
        reason: 'app_store_expired',
        feedback: `reconcile-cron: appleExpires=${transaction.expiresDate} < now`,
      } as any,
      metadata: {
        ...(sub.metadata || {}),
        app_store_reconciled_at: nowSec,
      },
    });
    createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
    return true;
  }

  return false;
}

// ---------------------------------------------------------------------------
// Google Play
// ---------------------------------------------------------------------------

export async function reconcileGooglePlay(batchSize = DEFAULT_BATCH_SIZE): Promise<ReconcileStats> {
  const stats = emptyStats();
  const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
  if (methods.length === 0) return stats;

  const subs = await Subscription.findAll({
    where: {
      status: { [Op.in]: ['active', 'past_due', 'trialing'] },
      'payment_details.google_play.purchase_token': { [Op.ne]: null } as any,
      updated_at: { [Op.lt]: new Date(Date.now() - RECENT_UPDATE_GUARD_MS) },
    } as any,
    order: [['updated_at', 'ASC']],
    limit: batchSize,
  });

  for (const sub of subs) {
    stats.checked += 1;
    try {
      const purchaseToken = sub.payment_details?.google_play?.purchase_token as string | undefined;
      const productId = sub.payment_details?.google_play?.product_id as string | undefined;
      if (!purchaseToken || !productId) continue;

      const method = pickIapMethodForSub(methods, sub);
      if (!method) {
        logger.warn('iap-reconcile: no matching google_play PaymentMethod', { subscriptionId: sub.id });
        continue;
      }
      let client: GooglePlayClient;
      try {
        client = method.getGooglePlayClient();
      } catch (err: any) {
        logger.warn('iap-reconcile: cannot build google_play client', { subscriptionId: sub.id, error: err?.message });
        continue;
      }

      const purchase = await client.getSubscription(productId, purchaseToken);
      const drifted = await applyGooglePlayPurchaseDrift(sub, purchase);
      if (drifted) stats.updated += 1;
    } catch (err: any) {
      stats.errors += 1;
      logger.error('iap-reconcile: google_play sub failed', { subscriptionId: sub.id, error: err?.message });
    }
  }
  return stats;
}

/**
 * Compare Google's authoritative `purchase` payload against our local row
 * and apply only meaningful drifts.
 *
 * Returns true if we wrote to the row.
 */
export async function applyGooglePlayPurchaseDrift(
  sub: Subscription,
  purchase: GooglePlaySubscriptionPurchase
): Promise<boolean> {
  const expirySec = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : undefined;
  // cancelReason: 0=user, 1=system, 2=replaced, 3=developer
  const revoked =
    typeof purchase.cancelReason === 'number' && purchase.cancelReason >= 0 && purchase.autoRenewing === false;

  // Renewal drift forward
  if (expirySec && expirySec > sub.current_period_end + 60) {
    await sub.update({
      status: 'active',
      current_period_end: expirySec,
      payment_details: {
        ...(sub.payment_details || {}),
        google_play: {
          ...((sub.payment_details?.google_play || {}) as any),
          expiry_time_millis: purchase.expiryTimeMillis,
        },
      },
      metadata: {
        ...(sub.metadata || {}),
        google_play_reconciled_at: Math.floor(Date.now() / 1000),
      },
    });
    createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
    return true;
  }

  // Auto-renew off (user canceled but still has paid period) → schedule cancel
  if (revoked && !sub.cancel_at_period_end) {
    await sub.update({
      cancel_at_period_end: true,
      cancelation_details: {
        reason: 'google_play_auto_renew_off',
        feedback: `reconcile-cron: cancelReason=${purchase.cancelReason} autoRenewing=false`,
      } as any,
      metadata: {
        ...(sub.metadata || {}),
        google_play_reconciled_at: Math.floor(Date.now() / 1000),
      },
    });
    createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
    return true;
  }

  // Past expiry — Google says we're expired but local still active.
  const nowSec = Math.floor(Date.now() / 1000);
  if (expirySec && expirySec < nowSec - 60 && (sub.status === 'active' || sub.status === 'trialing')) {
    await sub.update({
      status: 'canceled',
      canceled_at: nowSec,
      cancelation_details: {
        reason: 'google_play_expired',
        feedback: `reconcile-cron: googleExpires=${purchase.expiryTimeMillis} < now`,
      } as any,
      metadata: {
        ...(sub.metadata || {}),
        google_play_reconciled_at: nowSec,
      },
    });
    createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
    return true;
  }

  return false;
}
