// Google Play Real-Time Developer Notification — subscription state machine.
//
// Maps each notificationType to the local Subscription state, calls
// `client.acknowledgeSubscription` for new purchases (3-day deadline), and
// emits the `customer.subscription.*` events so EntitlementGrant gets derived
// downstream — exactly the same events the Stripe handler emits.

import { Op } from 'sequelize';

import { createEvent } from '../../../libs/audit';
import logger from '../../../libs/logger';
import { Customer, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
import { GooglePlayClient, GooglePlaySubscriptionPurchase } from '../client';

export const GooglePlayNotificationType = {
  SUBSCRIPTION_RECOVERED: 1,
  SUBSCRIPTION_RENEWED: 2,
  SUBSCRIPTION_CANCELED: 3,
  SUBSCRIPTION_PURCHASED: 4,
  SUBSCRIPTION_ON_HOLD: 5,
  SUBSCRIPTION_IN_GRACE_PERIOD: 6,
  SUBSCRIPTION_RESTARTED: 7,
  SUBSCRIPTION_PRICE_CHANGE_CONFIRMED: 8,
  SUBSCRIPTION_DEFERRED: 9,
  SUBSCRIPTION_PAUSED: 10,
  SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED: 11,
  SUBSCRIPTION_REVOKED: 12,
  SUBSCRIPTION_EXPIRED: 13,
} as const;

export type GooglePlaySubscriptionNotification = {
  version: string;
  notificationType: number;
  purchaseToken: string;
  subscriptionId: string;
};

/**
 * Look up an existing local Subscription by Google Play purchase token,
 * stored in `payment_details.google_play.purchase_token`.
 *
 * Prefers non-canceled rows so webhooks always target the live winner even if
 * dedup has marked race-loser duplicates canceled with the same purchaseToken.
 * Falls back to any row (oldest first) when nothing active remains — useful for
 * post-expiry webhooks that should still find the row to log against.
 */
async function findSubscriptionByPurchaseToken(purchaseToken: string): Promise<Subscription | null> {
  const active = await Subscription.findOne({
    where: {
      'payment_details.google_play.purchase_token': purchaseToken,
      status: { [Op.notIn]: ['canceled', 'incomplete_expired'] },
    } as any,
    order: [['created_at', 'ASC']],
  });
  if (active) return active;
  return Subscription.findOne({
    where: {
      'payment_details.google_play.purchase_token': purchaseToken,
    } as any,
    order: [['created_at', 'ASC']],
  });
}

/**
 * Reconcile race-created duplicates for the same purchaseToken. Keeps the
 * earliest-created active row, marks the rest canceled with a dedup marker so
 * audit can trace why. Safe to call concurrently — converges to the same
 * winner regardless of caller order.
 */
async function reconcilePurchaseTokenDuplicates(purchaseToken: string): Promise<Subscription> {
  const live = await Subscription.findAll({
    where: {
      'payment_details.google_play.purchase_token': purchaseToken,
      status: { [Op.notIn]: ['canceled', 'incomplete_expired'] },
    } as any,
    order: [['created_at', 'ASC']],
  });
  if (live.length === 0) {
    throw new Error(`reconcile: no live subscription found for purchaseToken ${purchaseToken}`);
  }
  // Non-null: guarded by the `live.length === 0` throw above. TS doesn't narrow
  // array index access, so assert explicitly.
  const winner = live[0]!;
  if (live.length === 1) return winner;
  const now = Math.floor(Date.now() / 1000);
  for (const dup of live.slice(1)) {
    // eslint-disable-next-line no-await-in-loop -- sequential cancellation to avoid concurrent writes on the same sub set
    await dup.update({
      status: 'canceled',
      canceled_at: now,
      metadata: {
        ...(dup.metadata || {}),
        dedup_replaced_by: winner.id,
        dedup_reason: 'race_duplicate_on_verify',
      },
    });
    logger.warn('google_play verify: race-duplicate Subscription marked canceled', {
      duplicateId: dup.id,
      winnerId: winner.id,
      purchaseToken,
    });
  }
  return winner;
}

/**
 * Look up the Customer that owns this purchase, via the
 * `obfuscatedExternalAccountId` set by the mobile SDK before purchase.
 */
function findCustomerByObfuscatedAccountId(uuid: string): Promise<Customer | null> {
  return Customer.findOne({ where: { google_play_uuid: uuid } });
}

export type HandleGooglePlayNotificationDeps = {
  packageName: string;
  client: GooglePlayClient;
  notification: GooglePlaySubscriptionNotification;
};

/**
 * Top-level dispatch for a subscriptionNotification payload.
 */
export async function handleGooglePlaySubscriptionEvent({
  packageName,
  client,
  notification,
}: HandleGooglePlayNotificationDeps): Promise<void> {
  const { notificationType, purchaseToken, subscriptionId } = notification;

  logger.info('received google_play subscription notification', {
    packageName,
    notificationType,
    subscriptionId,
  });

  if (notificationType === GooglePlayNotificationType.SUBSCRIPTION_PURCHASED) {
    await handlePurchased({ client, subscriptionId, purchaseToken });
    return;
  }

  const subscription = await findSubscriptionByPurchaseToken(purchaseToken);
  if (!subscription) {
    logger.warn('local subscription not found for google_play notification', {
      notificationType,
      purchaseToken,
      subscriptionId,
    });
    return;
  }

  switch (notificationType) {
    case GooglePlayNotificationType.SUBSCRIPTION_RENEWED:
    case GooglePlayNotificationType.SUBSCRIPTION_DEFERRED:
      await handleRenewedOrDeferred({ client, subscription, subscriptionId, purchaseToken });
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_RECOVERED:
    case GooglePlayNotificationType.SUBSCRIPTION_RESTARTED:
      await handleResumed(subscription);
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_ON_HOLD:
    case GooglePlayNotificationType.SUBSCRIPTION_IN_GRACE_PERIOD:
      await markPastDue(subscription);
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_PAUSED:
      await markPaused(subscription);
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_CANCELED:
      await scheduleCancelAtPeriodEnd(subscription);
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_EXPIRED:
      await markExpired(subscription);
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_REVOKED:
      await handleRevoked(subscription);
      break;

    case GooglePlayNotificationType.SUBSCRIPTION_PRICE_CHANGE_CONFIRMED:
    case GooglePlayNotificationType.SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED:
      // Informational only — store latest payload but no state transition
      await subscription.update({
        metadata: {
          ...(subscription.metadata || {}),
          google_play_last_notification_type: notificationType,
        },
      });
      break;

    default:
      logger.warn('unhandled google_play notificationType', { notificationType });
  }
}

async function handlePurchased({
  client,
  subscriptionId,
  purchaseToken,
}: {
  client: GooglePlayClient;
  subscriptionId: string;
  purchaseToken: string;
}): Promise<void> {
  // Critical: must call acknowledge within 3 days of SUBSCRIPTION_PURCHASED,
  // otherwise Google auto-refunds. Do it before any other work.
  try {
    await client.acknowledgeSubscription(subscriptionId, purchaseToken);
    logger.info('acknowledged google_play subscription', { subscriptionId, purchaseToken });
  } catch (err) {
    logger.error('failed to acknowledge google_play subscription', { err, subscriptionId, purchaseToken });
    throw err;
  }

  const purchase = await client.getSubscription(subscriptionId, purchaseToken);
  const customer = await resolveCustomer(purchase, purchaseToken);
  if (!customer) {
    logger.warn('orphan google_play purchase — no customer match; subscription not created', {
      subscriptionId,
      purchaseToken,
    });
    return;
  }

  // A2 mock phase: Subscription model wiring (Product/Price/SubscriptionItem
  // mapping from Google productId → local price) lands with the resource sync
  // commit. Here we only ensure ack happens and record the orphan-safety event.
  logger.info('google_play purchase resolved to customer (Subscription creation deferred to resource sync)', {
    customerId: customer.id,
    subscriptionId,
    purchaseToken,
  });
}

function resolveCustomer(purchase: GooglePlaySubscriptionPurchase, purchaseToken: string): Promise<Customer | null> {
  const uuid = purchase.obfuscatedExternalAccountId;
  if (!uuid) {
    logger.warn('google_play purchase has no obfuscatedExternalAccountId', { purchaseToken });
    return Promise.resolve(null);
  }
  return findCustomerByObfuscatedAccountId(uuid);
}

async function handleRenewedOrDeferred({
  client,
  subscription,
  subscriptionId,
  purchaseToken,
}: {
  client: GooglePlayClient;
  subscription: Subscription;
  subscriptionId: string;
  purchaseToken: string;
}): Promise<void> {
  const purchase = await client.getSubscription(subscriptionId, purchaseToken);
  const newExpiry = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : undefined;
  await subscription.update({
    status: 'active',
    current_period_end: newExpiry ?? subscription.current_period_end,
    payment_details: {
      ...(subscription.payment_details || {}),
      google_play: {
        ...(subscription.payment_details?.google_play || { purchase_token: purchaseToken, product_id: subscriptionId }),
        expiry_time_millis: purchase.expiryTimeMillis,
      },
    },
  });
  createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
}

async function handleResumed(subscription: Subscription): Promise<void> {
  await subscription.update({ status: 'active' });
  createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
}

async function markPastDue(subscription: Subscription): Promise<void> {
  if (subscription.status === 'past_due') return;
  await subscription.update({ status: 'past_due' });
}

async function markPaused(subscription: Subscription): Promise<void> {
  if (subscription.status === 'paused') return;
  await subscription.update({ status: 'paused' });
}

async function scheduleCancelAtPeriodEnd(subscription: Subscription): Promise<void> {
  await subscription.update({ cancel_at_period_end: true });
}

async function markExpired(subscription: Subscription): Promise<void> {
  if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
  await subscription.update({ status: 'canceled', canceled_at: Math.floor(Date.now() / 1000) });
  createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
}

/**
 * Client-initiated verify path (aistro-style).
 *
 * Mobile client posts the purchaseToken right after StoreKit / BillingClient
 * finishes a purchase. We verify against Google, ACK, and either find the
 * existing Subscription or create a new one. Idempotent — safe to call on
 * client-side retries.
 */
export async function ingestVerifiedGooglePlayPurchase({
  customerDid,
  paymentMethod,
  client,
  purchaseToken,
  subscriptionId,
}: {
  customerDid: string;
  paymentMethod: PaymentMethod;
  client: GooglePlayClient;
  purchaseToken: string;
  subscriptionId: string;
}): Promise<{
  subscription: Subscription;
  isFirstSubscribe: boolean;
  purchase: GooglePlaySubscriptionPurchase;
}> {
  // 1. Verify with Google (this is the only path to confirm authenticity).
  const purchase = await client.getSubscription(subscriptionId, purchaseToken);

  // 2. Idempotency: if Subscription already exists for this token, return it.
  const existing = await findSubscriptionByPurchaseToken(purchaseToken);
  if (existing) {
    // Best-effort: ACK if Google says not yet acknowledged.
    if (purchase.acknowledgementState === 0) {
      try {
        await client.acknowledgeSubscription(subscriptionId, purchaseToken);
      } catch (err) {
        logger.error('google_play verify: ACK failed on existing subscription', { err });
      }
    }

    // SKU drift sync — mirror of the app_store path. When the user
    // crossgrades / changes plan inside the same purchase token (Google
    // Play subscription product with multiple base plans), the
    // subscriptionId Google returns differs from what we stored. Update
    // payment_details + repoint SubscriptionItem at the new Price so
    // entitlement.check reflects the new tier without waiting for the
    // RTDN webhook.
    const storedGoogleSku = (existing as any).payment_details?.google_play?.product_id;
    if (storedGoogleSku && storedGoogleSku !== subscriptionId) {
      // Multi-tenant scoping: filter by (sku, package_name) — two Android
      // apps can have the same SKU string in their independent Play
      // Console namespaces; package_name from the client is the tenant key.
      const newPrice = await Price.findOne({
        where: {
          'metadata.google_play_product_id': subscriptionId,
          'metadata.package_name': client.packageName,
        } as any,
      });
      const newExpiresAt = purchase.expiryTimeMillis
        ? Math.floor(Number(purchase.expiryTimeMillis) / 1000)
        : (existing.current_period_end ?? undefined);
      const driftPatch: any = {
        payment_details: {
          ...(existing.payment_details || {}),
          google_play: {
            ...(existing.payment_details?.google_play || {}),
            product_id: subscriptionId,
          },
        },
        metadata: {
          ...(existing.metadata || {}),
          google_play_product_id: subscriptionId,
          product_id: newPrice?.product_id,
          price_id: newPrice?.id,
        },
      };
      if (newExpiresAt) driftPatch.current_period_end = newExpiresAt;
      await existing.update(driftPatch);
      if (newPrice) {
        const items = await SubscriptionItem.findAll({ where: { subscription_id: existing.id } as any });
        for (const item of items) {
          if ((item as any).price_id !== newPrice.id) {
            // eslint-disable-next-line no-await-in-loop -- sequential per-item updates to avoid concurrent writes on the same sub
            await item.update({ price_id: newPrice.id });
          }
        }
      }
      logger.info('google_play verify: SKU drift detected, synced to current Google-side product', {
        subscriptionId: existing.id,
        oldSku: storedGoogleSku,
        newSku: subscriptionId,
        newProductId: newPrice?.product_id,
        newPriceId: newPrice?.id,
      });
      return { subscription: existing, isFirstSubscribe: false, purchase };
    }

    logger.info('google_play verify: existing subscription, returning current state', {
      subscriptionId: existing.id,
      purchaseToken,
    });
    return { subscription: existing, isFirstSubscribe: false, purchase };
  }

  // 3. Refuse expired / unpurchased states.
  if (purchase.paymentState !== undefined && purchase.paymentState !== 1) {
    throw new Error(`google_play purchase paymentState=${purchase.paymentState} not active`);
  }
  const expiresAt = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : 0;
  if (!expiresAt || expiresAt * 1000 < Date.now()) {
    throw new Error('google_play purchase already expired');
  }

  // 4. findOrCreate Customer + persist UUID mapping (D-004) so future webhooks reverse-lookup works.
  const [customer] = await Customer.findOrCreate({
    where: { did: customerDid },
    defaults: {
      did: customerDid,
      livemode: !!paymentMethod.livemode,
      delinquent: false,
      invoice_prefix: Customer.getInvoicePrefix(),
    } as any,
  });
  if (purchase.obfuscatedExternalAccountId && customer.google_play_uuid !== purchase.obfuscatedExternalAccountId) {
    await customer.update({ google_play_uuid: purchase.obfuscatedExternalAccountId });
  }

  // 5. Map Google subscriptionId → local Price (Stripe-style: SKU binding lives
  //    on Price.metadata, not Product.metadata, so one Product can have N
  //    Prices with N SKUs — monthly / yearly / promo all under the same tier).
  //    Multi-tenant scoping: filter by (sku, package_name) — two Android
  //    apps can have the same SKU string in their independent Play Console
  //    namespaces; package_name from the client is the tenant discriminator.
  //    If missing, we still create the Subscription so we don't lose data, but
  //    log loudly so ops can fix the binding.
  const price = await Price.findOne({
    where: {
      'metadata.google_play_product_id': subscriptionId,
      'metadata.package_name': client.packageName,
    } as any,
  });
  if (!price) {
    logger.warn('google_play verify: no local Price mapped to (subscriptionId, packageName)', {
      subscriptionId,
      packageName: client.packageName,
    });
  }

  // 6. Is this the customer's first paid subscription on Google Play?
  const isFirstSubscribe =
    (await Subscription.count({
      where: { customer_id: customer.id, channel: 'google_play' } as any,
    })) === 0;

  // 7. ACK before creating local rows — if ACK fails we want to know early.
  if (purchase.acknowledgementState === 0) {
    await client.acknowledgeSubscription(subscriptionId, purchaseToken);
  }

  // 8. Create the Subscription.
  const now = Math.floor(Date.now() / 1000);
  const subscription = await Subscription.create({
    livemode: !!paymentMethod.livemode,
    customer_id: customer.id,
    currency_id: paymentMethod.default_currency_id ?? '',
    default_payment_method_id: paymentMethod.id,
    status: 'active',
    channel: 'google_play',
    environment: 'production',
    current_period_start: purchase.startTimeMillis ? Math.floor(Number(purchase.startTimeMillis) / 1000) : now,
    current_period_end: expiresAt,
    cancel_at_period_end: false,
    billing_cycle_anchor: now,
    collection_method: 'charge_automatically',
    start_date: now,
    metadata: {
      google_play_product_id: subscriptionId,
      product_id: price?.product_id,
      price_id: price?.id,
    },
    payment_details: {
      google_play: {
        purchase_token: purchaseToken,
        product_id: subscriptionId,
        // Multi-tenant: persist the verified package_name so cross-channel
        // entitlement lookups can disambiguate same-SKU collisions across
        // different Android apps wired into the same Payment Kit instance.
        package_name: client.packageName,
        order_id: purchase.orderId,
        expiry_time_millis: purchase.expiryTimeMillis,
        environment: 'production',
      },
    },
    pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
  } as any);

  // 9. Create a SubscriptionItem pointing at the exact Price the customer
  //    paid for. Stripe-style: monthly vs yearly vs promo distinction is
  //    preserved here, not collapsed to the Product's default_price. Without
  //    the item, downstream lookups that walk Subscription → items → price →
  //    product (entitlement queries, invoice/notification templates that call
  //    getMainProductName, etc.) all render empty for IAP-channel subs.
  if (price) {
    await SubscriptionItem.create({
      subscription_id: subscription.id,
      price_id: price.id,
      quantity: 1,
      livemode: subscription.livemode,
      metadata: {},
    } as any);
  }

  // 10. Race-loser reconciliation. Concurrent /verify calls for the same
  //     purchaseToken (Google's BillingClient fires onPurchasesUpdated multiple
  //     times in some scenarios — network blips, BillingClient reconnect, retry
  //     after orphan ack) all pass the step-2 idempotency check before any of
  //     them commit a row. We can't acquire a row-level lock on D1, so the
  //     cheapest correct fix is post-create reconciliation: each writer scans
  //     for siblings, the earliest-created one wins, the rest mark themselves
  //     canceled so subsequent webhooks target only the winner.
  const winner = await reconcilePurchaseTokenDuplicates(purchaseToken);
  if (winner.id !== subscription.id) {
    logger.info('google_play verify: lost create race, returning earliest sibling as winner', {
      myId: subscription.id,
      winnerId: winner.id,
      purchaseToken,
    });
    return { subscription: winner, isFirstSubscribe: false, purchase };
  }

  createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
  logger.info('google_play verify: subscription created', {
    subscriptionId: subscription.id,
    customerId: customer.id,
    productId: price?.product_id,
    priceId: price?.id,
  });

  return { subscription, isFirstSubscribe, purchase };
}

async function handleRevoked(subscription: Subscription): Promise<void> {
  const now = Math.floor(Date.now() / 1000);
  await subscription.update({
    status: 'canceled',
    ended_at: now,
    cancelation_details: {
      ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
      reason: 'cancellation_requested',
    },
    metadata: {
      ...(subscription.metadata || {}),
      refunded: true,
      refunded_at: now,
    },
  });

  // IAP refunds are mirrored as subscription state only — the canonical refund
  // record lives at Google Play. We deliberately do NOT create a local Refund
  // ledger row; user-facing refund/receipt history is in the Play Store.
  // See docs/superpowers/specs/2026-06-04-iap-records-organization-design.md.
  logger.info('google_play SUBSCRIPTION_REVOKED — status mirrored to canceled (refunded)', {
    subscriptionId: subscription.id,
  });

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