// App Store — subscription verification & ingest.
//
// A1 mock phase: only the client-initiated verify path is implemented (mirror
// of `integrations/google-play/handlers/subscription.ts`). Apple's S2S
// notifications (App Store Server Notifications V2) will land alongside A2's
// Pub/Sub webhook in a later commit.
//
// Idempotency: the stable key across renewals is `originalTransactionId` — Apple
// keeps it the same for every renewal of the same subscription, while
// `transactionId` changes per charge. We store both in payment_details for
// audit, but uniqueness/de-dup keys off originalTransactionId.

import { createEvent } from '../../../libs/audit';
import logger from '../../../libs/logger';
import { Customer, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
import {
  AppStoreClient,
  AppStoreNotificationPayload,
  AppStoreNotificationType,
  AppStoreTransactionPayload,
} from '../client';

/**
 * Thrown when a verify/restore presents an Apple subscription that already
 * belongs to a different customer (DID). Apple subscriptions are NOT transferred
 * between accounts — the client should prompt the user to sign in with the
 * owning account. Mirrors OpenAI/Claude ("this subscription is associated with
 * another account").
 */
export class AppStoreOwnershipMismatchError extends Error {
  code = 'subscription_owned_by_another_account';

  constructor(message: string) {
    super(message);
    this.name = 'AppStoreOwnershipMismatchError';
  }
}

/**
 * Look up an existing local Subscription by Apple's originalTransactionId,
 * stored at `payment_details.app_store.original_transaction_id`. JSON path
 * lookup is dialect-specific; matches the existing google_play pattern.
 */
function findSubscriptionByOriginalTransactionId(originalTransactionId: string): Promise<Subscription | null> {
  return Subscription.findOne({
    where: {
      'payment_details.app_store.original_transaction_id': originalTransactionId,
    } as any,
  });
}

/**
 * Client-initiated verify path (StoreKit 2 JWS).
 *
 * Mobile client posts the `jws_signed_transaction` right after
 * `Product.purchase(...)` succeeds. We decode + verify the JWS (mock phase:
 * decode only — see client.ts), then either return the existing Subscription
 * or create a new one. Idempotent — safe to call on client-side retries.
 */
export async function ingestVerifiedAppStorePurchase({
  customerDid,
  paymentMethod,
  client,
  signedTransaction,
  receipt,
  expectedProductIds,
}: {
  customerDid: string;
  paymentMethod: PaymentMethod;
  client: AppStoreClient;
  /** StoreKit 2 JWS string (preferred when present) */
  signedTransaction?: string;
  /** StoreKit 1 legacy base64 receipt (fallback) */
  receipt?: string;
  /** Optional productId allowlist for legacy receipt filtering. */
  expectedProductIds?: string[];
}): Promise<{
  subscription: Subscription;
  isFirstSubscribe: boolean;
  transaction: AppStoreTransactionPayload;
}> {
  if (!signedTransaction && !receipt) {
    throw new Error('app_store verify: must provide signedTransaction or receipt');
  }

  // 1. Verify via the right path. JWS wins if both are present (aistro pattern).
  const transaction = signedTransaction
    ? await client.verifyJwsTransaction(signedTransaction)
    : await client.verifyLegacyReceipt(receipt!, { expectedProductIds });

  // 2. Idempotency: if Subscription already exists for this originalTransactionId.
  const existing = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
  if (existing) {
    // Ownership guard (A+): an Apple subscription (one originalTransactionId /
    // Apple account) is bound to the first DID that claimed it. If a DIFFERENT
    // DID verifies/restores it, do NOT migrate — refuse so the client can prompt
    // the user to sign in with the owning account. Subscriptions are not
    // transferred between accounts. Mirrors OpenAI/Claude.
    const owner = await Customer.findByPk(existing.customer_id);
    if (owner && owner.did !== customerDid) {
      logger.warn('app_store verify: subscription owned by a different DID — refusing', {
        subscriptionId: existing.id,
        ownerDid: owner.did,
        requestDid: customerDid,
      });
      throw new AppStoreOwnershipMismatchError(
        'This App Store subscription is linked to a different account. Please sign in with that account to use it.'
      );
    }

    const newExpiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
    const newTxnValid = !!newExpiresAt && newExpiresAt * 1000 > Date.now();
    const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string);
    // Sandbox re-subscribe / renewal arriving via client verify reuses the SAME
    // originalTransactionId. If the stored sub had lapsed (expired→canceled) but
    // the new transaction is valid, reactivate it instead of returning the stale
    // canceled row — otherwise a re-purchase records nothing and the user stays
    // "not subscribed". Active subs are returned untouched (the S2S webhook owns
    // renewal bookkeeping for the active path).
    if (lapsed && newTxnValid) {
      const updatePatch: any = {
        status: 'active',
        current_period_end: newExpiresAt,
        ended_at: null,
        cancel_at_period_end: false,
        payment_details: {
          ...(existing.payment_details || {}),
          app_store: {
            ...(existing.payment_details?.app_store || {}),
            transaction_id: transaction.transactionId,
            expires_at: newExpiresAt,
          },
        },
      };
      // Self-heal: if the Product↔SKU mapping was added after the original
      // purchase, metadata.product_id was frozen empty. Resolve it now so the
      // admin can show the product name (entitlement already resolves live; this
      // only fixes the stored display snapshot).
      if (!(existing.metadata as any)?.product_id) {
        // Multi-tenant scoping: filter by (sku, bundle_id) — two different
        // iOS apps can have the same SKU string in their own App Store
        // Connect namespaces; bundle_id from the JWS is the tenant key.
        const mappedPrice = await Price.findOne({
          where: {
            'metadata.app_store_product_id': transaction.productId,
            'metadata.bundle_id': transaction.bundleId,
          } as any,
        });
        if (mappedPrice) {
          updatePatch.metadata = {
            ...(existing.metadata || {}),
            app_store_product_id: transaction.productId,
            product_id: mappedPrice.product_id,
            price_id: mappedPrice.id,
          };
        }
      }
      await existing.update(updatePatch);
      createEvent('Subscription', 'customer.subscription.started', existing).catch(console.error);
      logger.info('app_store verify: reactivated lapsed subscription from fresh transaction', {
        subscriptionId: existing.id,
        originalTransactionId: transaction.originalTransactionId,
        newExpiresAt,
      });
      return { subscription: existing, isFirstSubscribe: false, transaction };
    }

    // SKU drift sync — when the StoreKit-side product_id differs from
    // what we have stored (Apple crossgrade / downgrade landed in
    // Sandbox immediately; or the production DID_RENEW webhook hadn't
    // landed yet and the client is forcing a re-verify), update our
    // snapshot + repoint the SubscriptionItem at the new Price so
    // entitlement.check reflects the new tier without waiting for the
    // webhook. Without this, `purchase.restore()` after a tier change
    // is a no-op — exactly the bug surfaced during real-device verify.
    const storedAppStoreSku = (existing as any).payment_details?.app_store?.product_id;
    if (storedAppStoreSku && storedAppStoreSku !== transaction.productId) {
      const newPrice = await Price.findOne({
        where: {
          'metadata.app_store_product_id': transaction.productId,
          'metadata.bundle_id': transaction.bundleId,
        } as any,
      });
      const driftPatch: any = {
        payment_details: {
          ...(existing.payment_details || {}),
          app_store: {
            ...(existing.payment_details?.app_store || {}),
            product_id: transaction.productId,
            transaction_id: transaction.transactionId,
          },
        },
        metadata: {
          ...(existing.metadata || {}),
          app_store_product_id: transaction.productId,
          product_id: newPrice?.product_id,
          price_id: newPrice?.id,
        },
      };
      if (newExpiresAt) driftPatch.current_period_end = newExpiresAt;
      await existing.update(driftPatch);

      // Repoint SubscriptionItem(s) at the new Price so admin views and
      // any items→price→product walks see the right tier. Idempotent —
      // hitting the same Price twice is a no-op update.
      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('app_store verify: SKU drift detected, synced to current Apple-side product', {
        subscriptionId: existing.id,
        oldSku: storedAppStoreSku,
        newSku: transaction.productId,
        newProductId: newPrice?.product_id,
        newPriceId: newPrice?.id,
      });
      return { subscription: existing, isFirstSubscribe: false, transaction };
    }

    logger.info('app_store verify: existing subscription, returning current state', {
      subscriptionId: existing.id,
      originalTransactionId: transaction.originalTransactionId,
    });
    return { subscription: existing, isFirstSubscribe: false, transaction };
  }

  // 3. Refuse expired subscriptions.
  const expiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
  if (!expiresAt || expiresAt * 1000 < Date.now()) {
    throw new Error('app_store purchase already expired');
  }

  // 4. findOrCreate Customer + persist appAccountToken so future S2S notifications can reverse-lookup.
  const [customer] = await Customer.findOrCreate({
    where: { did: customerDid },
    defaults: {
      did: customerDid,
      livemode: !!paymentMethod.livemode,
      delinquent: false,
      invoice_prefix: Customer.getInvoicePrefix(),
    } as any,
  });
  if (transaction.appAccountToken && customer.app_store_uuid !== transaction.appAccountToken) {
    await customer.update({ app_store_uuid: transaction.appAccountToken });
  }

  // 5. Map Apple productId → 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 map under the same tier).
  //    Multi-tenant scoping: filter by (sku, bundle_id) — two iOS apps can
  //    have the same SKU string in their independent App Store Connect
  //    namespaces; bundle_id from the JWS is the tenant discriminator.
  //    Log but don't block if the mapping is missing; admin can wire it later
  //    and the entitlement layer resolves live (same policy as google_play).
  const price = await Price.findOne({
    where: {
      'metadata.app_store_product_id': transaction.productId,
      'metadata.bundle_id': transaction.bundleId,
    } as any,
  });
  if (!price) {
    logger.warn('app_store verify: no local Price mapped to (productId, bundleId)', {
      productId: transaction.productId,
      bundleId: transaction.bundleId,
    });
  }

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

  // 7. Create the Subscription.
  const now = Math.floor(Date.now() / 1000);
  const purchaseStartedAt = transaction.purchaseDate ? Math.floor(Number(transaction.purchaseDate) / 1000) : now;
  const environment = transaction.environment === 'Production' ? 'production' : 'sandbox';
  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: 'app_store',
    environment,
    current_period_start: purchaseStartedAt,
    current_period_end: expiresAt,
    cancel_at_period_end: false,
    billing_cycle_anchor: now,
    collection_method: 'charge_automatically',
    start_date: now,
    metadata: {
      app_store_product_id: transaction.productId,
      product_id: price?.product_id,
      price_id: price?.id,
    },
    payment_details: {
      app_store: {
        original_transaction_id: transaction.originalTransactionId,
        transaction_id: transaction.transactionId,
        product_id: transaction.productId,
        // Multi-tenant: persist the verified bundle_id so cross-channel
        // entitlement lookups can disambiguate same-SKU collisions across
        // different iOS apps wired into the same Payment Kit instance.
        bundle_id: transaction.bundleId,
        web_order_line_item_id: transaction.webOrderLineItemId,
        environment: transaction.environment,
        expires_at: expiresAt,
      },
    },
    pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
  } as any);

  // 8. Create a SubscriptionItem pointing at the exact Price the customer
  //    paid for (monthly vs yearly vs promo distinction is preserved here,
  //    not collapsed to the Product's default_price). Without the item, the
  //    admin product column + any items→price→product walk (entitlement,
  //    invoice/notification templates) render empty for App Store subs.
  if (price) {
    await SubscriptionItem.create({
      subscription_id: subscription.id,
      price_id: price.id,
      quantity: 1,
      livemode: subscription.livemode,
      metadata: {},
    } as any);
  }

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

  return { subscription, isFirstSubscribe, transaction };
}

/**
 * Top-level dispatch for an App Store Server Notification V2 (already
 * decoded by routes/integrations/app-store.ts). Mirrors google_play's
 * handleGooglePlaySubscriptionEvent shape: map Apple `notificationType` to
 * the local Subscription state machine and emit `customer.subscription.*`
 * events so the downstream entitlement flow stays channel-agnostic.
 *
 * @param notification — outer signedPayload decoded
 * @param transaction — inner signedTransactionInfo decoded by caller
 *                      (we don't decode here because the caller already has
 *                      the AppStoreClient and may want to apply additional
 *                      validation before forwarding to the state machine).
 */
export async function handleAppStoreSubscriptionEvent({
  notification,
  transaction,
}: {
  notification: AppStoreNotificationPayload;
  transaction: AppStoreTransactionPayload;
}): Promise<void> {
  const { notificationType, subtype, notificationUUID } = notification;
  logger.info('received app_store notification', {
    notificationType,
    subtype,
    notificationUUID,
    originalTransactionId: transaction.originalTransactionId,
  });

  // SUBSCRIBED / OFFER_REDEEMED create the initial subscription; the others
  // require an existing local Subscription to operate on.
  if (notificationType === 'SUBSCRIBED' || notificationType === 'OFFER_REDEEMED') {
    await handleAppStoreSubscribed({ transaction, environment: notification.data.environment });
    return;
  }

  const subscription = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
  if (!subscription) {
    logger.warn('local subscription not found for app_store notification', {
      notificationType,
      originalTransactionId: transaction.originalTransactionId,
    });
    return;
  }

  switch (notificationType as AppStoreNotificationType) {
    case 'DID_RENEW':
      await handleAppStoreRenewed(subscription, transaction);
      break;

    case 'EXPIRED':
    case 'GRACE_PERIOD_EXPIRED':
      await markAppStoreExpired(subscription);
      break;

    case 'DID_FAIL_TO_RENEW':
      await markAppStorePastDue(subscription);
      break;

    case 'DID_CHANGE_RENEWAL_STATUS':
      if (subtype === 'AUTO_RENEW_DISABLED') {
        await scheduleAppStoreCancelAtPeriodEnd(subscription);
      } else if (subtype === 'AUTO_RENEW_ENABLED') {
        await unscheduleAppStoreCancel(subscription);
      }
      break;

    case 'REVOKE':
    case 'REFUND':
      await handleAppStoreRevoked(subscription, notificationType, subtype);
      break;

    case 'REFUND_REVERSED':
      // Apple reversed a previous refund. If we marked canceled because of the
      // refund, we *should* reactivate — but only the human ops team can be
      // sure right now. Log loudly and record metadata; reactivation is manual.
      await subscription.update({
        metadata: {
          ...(subscription.metadata || {}),
          app_store_last_notification_type: notificationType,
          app_store_refund_reversed_at: Math.floor(Date.now() / 1000),
        },
      });
      logger.warn('app_store REFUND_REVERSED received — manual review recommended', {
        subscriptionId: subscription.id,
      });
      break;

    case 'PRICE_INCREASE':
    case 'DID_CHANGE_RENEWAL_PREF':
    case 'RENEWAL_EXTENDED':
    case 'RENEWAL_EXTENSION':
    case 'REFUND_DECLINED':
    case 'CONSUMPTION_REQUEST':
      // Informational only — record latest notification type but no state transition.
      await subscription.update({
        metadata: {
          ...(subscription.metadata || {}),
          app_store_last_notification_type: notificationType,
          app_store_last_notification_subtype: subtype,
        },
      });
      break;

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

async function handleAppStoreSubscribed({
  transaction,
}: {
  transaction: AppStoreTransactionPayload;
  environment: 'Production' | 'Sandbox';
}): Promise<void> {
  // Idempotency: if the Subscription already exists (e.g. client beat the
  // webhook with /verify), there's nothing to do.
  const existing = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
  if (existing) {
    logger.info('app_store SUBSCRIBED — local subscription already exists, no-op', {
      subscriptionId: existing.id,
      originalTransactionId: transaction.originalTransactionId,
    });
    return;
  }

  // Try to map back to a Customer via appAccountToken (Apple's equivalent of
  // obfuscatedExternalAccountId). Webhook-first purchases without a prior
  // client verify happen on family-share, restore-purchase, and TestFlight
  // paths — log loudly so ops can chase the orphan.
  if (!transaction.appAccountToken) {
    logger.warn('app_store SUBSCRIBED with no appAccountToken — orphan purchase, cannot map to customer', {
      originalTransactionId: transaction.originalTransactionId,
      productId: transaction.productId,
    });
    return;
  }
  const customer = await Customer.findOne({ where: { app_store_uuid: transaction.appAccountToken } });
  if (!customer) {
    logger.warn('app_store SUBSCRIBED — no Customer matches appAccountToken', {
      appAccountToken: transaction.appAccountToken,
      originalTransactionId: transaction.originalTransactionId,
    });
    return;
  }

  // Find the app_store PaymentMethod for this livemode (assume one per livemode,
  // mirrors the route-level constraint).
  const paymentMethod = await PaymentMethod.findOne({
    where: { type: 'app_store', active: true, livemode: customer.livemode },
  });
  if (!paymentMethod) {
    logger.error('app_store SUBSCRIBED — no active PaymentMethod, skipping Subscription create');
    return;
  }

  // Multi-tenant: scope the SKU lookup to this app's bundle_id so a
  // collision (two apps with same SKU string) routes to the right Price.
  const price = await Price.findOne({
    where: {
      'metadata.app_store_product_id': transaction.productId,
      'metadata.bundle_id': transaction.bundleId,
    } as any,
  });

  const now = Math.floor(Date.now() / 1000);
  const expiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
  const purchaseStartedAt = transaction.purchaseDate ? Math.floor(Number(transaction.purchaseDate) / 1000) : now;
  const subscription = await Subscription.create({
    livemode: customer.livemode,
    customer_id: customer.id,
    currency_id: paymentMethod.default_currency_id ?? '',
    default_payment_method_id: paymentMethod.id,
    status: 'active',
    channel: 'app_store',
    environment: transaction.environment === 'Production' ? 'production' : 'sandbox',
    current_period_start: purchaseStartedAt,
    current_period_end: expiresAt || now,
    cancel_at_period_end: false,
    billing_cycle_anchor: now,
    collection_method: 'charge_automatically',
    start_date: now,
    metadata: {
      app_store_product_id: transaction.productId,
      product_id: price?.product_id,
      price_id: price?.id,
    },
    payment_details: {
      app_store: {
        original_transaction_id: transaction.originalTransactionId,
        transaction_id: transaction.transactionId,
        product_id: transaction.productId,
        // Multi-tenant: persist the verified bundle_id (same rationale as
        // the client-verify path above).
        bundle_id: transaction.bundleId,
        web_order_line_item_id: transaction.webOrderLineItemId,
        environment: transaction.environment,
        expires_at: expiresAt,
      },
    },
    pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
  } as any);

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

async function handleAppStoreRenewed(
  subscription: Subscription,
  transaction: AppStoreTransactionPayload
): Promise<void> {
  const newExpiry = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : undefined;
  await subscription.update({
    status: 'active',
    current_period_end: newExpiry ?? subscription.current_period_end,
    payment_details: {
      ...(subscription.payment_details || {}),
      app_store: {
        ...(subscription.payment_details?.app_store || {
          original_transaction_id: transaction.originalTransactionId,
          product_id: transaction.productId,
        }),
        transaction_id: transaction.transactionId,
        expires_at: newExpiry,
      },
    },
  });
  createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
}

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

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

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

async function unscheduleAppStoreCancel(subscription: Subscription): Promise<void> {
  if (!subscription.cancel_at_period_end) return;
  await subscription.update({ cancel_at_period_end: false });
}

async function handleAppStoreRevoked(
  subscription: Subscription,
  notificationType: string,
  subtype: string | undefined
): Promise<void> {
  const isRefund = notificationType === 'REFUND';
  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 || {}),
      app_store_last_notification_type: notificationType,
      app_store_last_notification_subtype: subtype,
      ...(isRefund ? { refunded: true, refunded_at: now } : {}),
    },
  });

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

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