// App Store integration client wrapper.
//
// Primary verify path is **client-side initiated** (mirrors A2 google_play):
// the mobile client posts a `jws_signed_transaction` (StoreKit 2) right after a
// purchase finishes. Apple's JWS carries a full self-attesting payload signed
// by Apple — no shared secret, no server callback to Apple needed for the
// happy path. We only hit App Store Server API when we need fresher state
// (e.g. SUBSCRIPTION_STATUS at renewal time).
//
// A1 mock phase: `verifyJwsTransaction` decodes the JWT payload **without**
// signature verification, so we can wire end-to-end with test fixtures. Real
// signature verification (Apple Root CA → leaf cert → RS256) is gated behind
// the same TODO marker as `google-play/verify.ts`.
//
// Write methods (cancel / refund) intentionally throw unless the
// APP_STORE_WRITE_ENABLED env flag is set — same safety rail as google_play.
//
// Tests replace this class via jest mocks.

import { config as configApple, validate as validateAppleReceipt } from 'node-apple-receipt-verify';

import logger from '../../libs/logger';
import {
  AppStoreApiCredentials,
  getAllSubscriptionStatuses,
  verifySignedNotification,
  verifySignedTransaction,
} from './signed-data-verifier';

/** App Store Server Notification V2 `notificationType` — Apple-defined string enum. */
export type AppStoreNotificationType =
  | 'SUBSCRIBED'
  | 'DID_RENEW'
  | 'EXPIRED'
  | 'DID_FAIL_TO_RENEW'
  | 'GRACE_PERIOD_EXPIRED'
  | 'DID_CHANGE_RENEWAL_STATUS'
  | 'DID_CHANGE_RENEWAL_PREF'
  | 'OFFER_REDEEMED'
  | 'PRICE_INCREASE'
  | 'REFUND'
  | 'REFUND_DECLINED'
  | 'REFUND_REVERSED'
  | 'REVOKE'
  | 'CONSUMPTION_REQUEST'
  | 'RENEWAL_EXTENDED'
  | 'RENEWAL_EXTENSION'
  | 'TEST';

/** Decoded payload of the outer `signedPayload` JWS in an App Store Server Notification V2. */
export type AppStoreNotificationPayload = {
  notificationType: AppStoreNotificationType | string;
  /** Optional subtype (e.g. INITIAL_BUY / RESUBSCRIBE / VOLUNTARY / AUTO_RENEW_DISABLED / GRACE_PERIOD). */
  subtype?: string;
  /** UUID Apple generates per notification — use this for idempotency. */
  notificationUUID: string;
  version: string;
  signedDate: number;
  data: {
    appAppleId?: number;
    bundleId: string;
    bundleVersion?: string;
    environment: 'Production' | 'Sandbox';
    /** Inner JWS carrying the transaction details (decode with verifyJwsTransaction). */
    signedTransactionInfo?: string;
    /** Inner JWS carrying renewal info — auto-renew state, expiration intent, etc. */
    signedRenewalInfo?: string;
    /** Subscription status: 1=active, 2=expired, 3=billing-retry, 4=grace, 5=revoked. */
    status?: 1 | 2 | 3 | 4 | 5;
  };
};

/** Decoded payload of a StoreKit 2 `signedTransactionInfo` JWS. */
export type AppStoreTransactionPayload = {
  /** Apple's internal id for this specific charge */
  transactionId: string;
  /** Stable id across renewals (= subscription "thread") */
  originalTransactionId: string;
  /** Apple SKU (the Product Id configured in App Store Connect) */
  productId: string;
  /** RFC3339 millis (string in some SDK versions, number in others — normalize to number) */
  purchaseDate?: number;
  /** Subscription-only: expiry of this purchase */
  expiresDate?: number;
  /** UUID set by client via `Product.purchase(options:)` — our equivalent of obfuscatedExternalAccountId */
  appAccountToken?: string;
  /** "Production" | "Sandbox" */
  environment: 'Production' | 'Sandbox';
  /** When Apple signed this JWS */
  signedDate?: number;
  /** App bundle id, must match our configured one */
  bundleId: string;
  /** "AUTO_RENEWABLE" | "NON_RENEWABLE" | "CONSUMABLE" | "NON_CONSUMABLE" */
  type?: string;
  /** webOrderLineItemId — Apple-side line item id per renewal */
  webOrderLineItemId?: string;
  /** Set when Apple revokes the transaction (refund / family-sharing removal). Unix ms. */
  revocationDate?: number;
  /** Code why the transaction was revoked: 0=unknown, 1=app-issue. */
  revocationReason?: number;
};

export type AppStoreSettings = {
  bundle_id: string;
  /** Constrained at runtime in `fromSettings`; widened here to align with PaymentMethodSettings (LiteralUnion). */
  environment: string;
  /** App-Specific Shared Secret — only needed for StoreKit 1 legacy receipts. */
  shared_secret?: string;
  /** App Store Server API credentials (only needed when calling Apple back for state refresh, NOT for JWS verify). */
  issuer_id?: string;
  key_id?: string;
  private_key_pem?: string;
};

const WRITE_ENABLED = process.env.APP_STORE_WRITE_ENABLED === 'true';

export class AppStoreClient {
  declare readonly bundleId: string;

  declare readonly environment: 'production' | 'sandbox';

  private readonly sharedSecret?: string;

  private readonly serverApiCredentials?: {
    issuerId: string;
    keyId: string;
    privateKeyPem: string;
  };

  private constructor(settings: AppStoreSettings, environment: 'production' | 'sandbox') {
    this.bundleId = settings.bundle_id;
    this.environment = environment;
    this.sharedSecret = settings.shared_secret;
    if (settings.issuer_id && settings.key_id && settings.private_key_pem) {
      this.serverApiCredentials = {
        issuerId: settings.issuer_id,
        keyId: settings.key_id,
        privateKeyPem: settings.private_key_pem,
      };
    }
  }

  public static fromSettings(settings: AppStoreSettings): AppStoreClient {
    if (!settings.bundle_id) {
      throw new Error('AppStoreClient: bundle_id is required');
    }
    if (settings.environment !== 'production' && settings.environment !== 'sandbox') {
      throw new Error(`AppStoreClient: environment must be production|sandbox, got ${settings.environment}`);
    }
    return new AppStoreClient(settings, settings.environment);
  }

  /**
   * Verify + decode a StoreKit 2 signedTransactionInfo JWS.
   *
   * Delegates to Apple's official SignedDataVerifier:
   *   1. Splits JWS, pulls leaf cert from header.x5c[0]
   *   2. Verifies x5c chain against bundled Apple Root CAs
   *   3. ES256 signature check on the payload
   *
   * Apple's verifyAndDecodeTransaction does NOT enforce bundleId — we layer
   * that check here. Set `APP_STORE_SKIP_SIGNATURE_VERIFY=true` to bypass
   * (decode only) for unit tests and emergency sandbox debugging.
   */
  public async verifyJwsTransaction(jws: string): Promise<AppStoreTransactionPayload> {
    const decoded = await verifySignedTransaction(jws, this.bundleId, this.environment);

    if (decoded.bundleId && decoded.bundleId !== this.bundleId) {
      throw new Error(`AppStoreClient: bundleId mismatch — JWS says ${decoded.bundleId}, configured ${this.bundleId}`);
    }
    if (!decoded.transactionId || !decoded.productId) {
      throw new Error('AppStoreClient: JWS payload missing transactionId or productId');
    }

    logger.debug('App Store JWS verified + decoded', {
      transactionId: decoded.transactionId,
      productId: decoded.productId,
      environment: decoded.environment,
    });

    return {
      transactionId: decoded.transactionId,
      originalTransactionId: decoded.originalTransactionId ?? decoded.transactionId,
      productId: decoded.productId,
      purchaseDate: decoded.purchaseDate,
      expiresDate: decoded.expiresDate,
      appAccountToken: decoded.appAccountToken,
      environment: (decoded.environment as 'Production' | 'Sandbox') ?? 'Sandbox',
      signedDate: decoded.signedDate,
      bundleId: decoded.bundleId ?? this.bundleId,
      type: decoded.type,
      webOrderLineItemId: decoded.webOrderLineItemId,
      revocationDate: (decoded as any).revocationDate,
      revocationReason: (decoded as any).revocationReason,
    };
  }

  /**
   * Verify + decode the outer signedPayload from an App Store Server
   * Notification V2. Apple's SignedDataVerifier handles the cert chain +
   * ES256 signature; we layer bundleId enforcement on top because the SDK
   * trusts the configured bundleId but doesn't reject mismatching payloads.
   */
  public async verifyNotificationPayload(signedPayload: string): Promise<AppStoreNotificationPayload> {
    const decoded = await verifySignedNotification(signedPayload, this.bundleId, this.environment);

    if (decoded.data?.bundleId && decoded.data.bundleId !== this.bundleId) {
      throw new Error(
        `AppStoreClient: notification bundleId mismatch — JWS says ${decoded.data.bundleId}, configured ${this.bundleId}`
      );
    }
    if (!decoded.notificationType || !decoded.notificationUUID) {
      throw new Error('AppStoreClient: notification payload missing notificationType or notificationUUID');
    }

    logger.debug('App Store notification verified + decoded', {
      notificationType: decoded.notificationType,
      subtype: decoded.subtype,
      notificationUUID: decoded.notificationUUID,
    });

    return {
      notificationType: decoded.notificationType as string,
      subtype: decoded.subtype as string | undefined,
      notificationUUID: decoded.notificationUUID,
      version: decoded.version ?? '2.0',
      signedDate: decoded.signedDate ?? Date.now(),
      data: {
        appAppleId: decoded.data?.appAppleId,
        bundleId: decoded.data?.bundleId ?? this.bundleId,
        bundleVersion: decoded.data?.bundleVersion,
        environment: (decoded.data?.environment as 'Production' | 'Sandbox') ?? 'Sandbox',
        signedTransactionInfo: decoded.data?.signedTransactionInfo,
        signedRenewalInfo: decoded.data?.signedRenewalInfo,
        status: decoded.data?.status as 1 | 2 | 3 | 4 | 5 | undefined,
      },
    };
  }

  /**
   * Verify a StoreKit 1 (legacy) base64 receipt via Apple's verifyReceipt endpoint.
   *
   * Calls `node-apple-receipt-verify` which POSTs to
   *   https://buy.itunes.apple.com/verifyReceipt   (production)
   *   https://sandbox.itunes.apple.com/verifyReceipt (sandbox, auto fallback)
   *
   * Returns a payload shaped like a StoreKit 2 transaction so downstream code
   * (ingestVerifiedAppStorePurchase) can stay agnostic. Note: legacy receipts
   * do NOT carry an appAccountToken — there's no client-set UUID for reverse
   * lookup. Callers should rely on the customer's session identity instead.
   *
   * @param expectedProductIds — only items matching one of these productIds are kept.
   *   If unset, all items are kept (receipt may contain multiple SKUs).
   */
  public async verifyLegacyReceipt(
    receipt: string,
    options: { expectedProductIds?: string[] } = {}
  ): Promise<AppStoreTransactionPayload> {
    if (!this.sharedSecret) {
      throw new Error('AppStoreClient: shared_secret is required for legacy receipt verification');
    }

    // `node-apple-receipt-verify` uses a module-level config. We re-configure on
    // every call so that multiple PaymentMethods (different secrets) don't
    // shadow each other.
    configApple({
      secret: this.sharedSecret,
      verbose: false,
      environment: ['production', 'sandbox'],
    });

    const items = (await validateAppleReceipt({ receipt })) as Array<any>;
    const filtered = options.expectedProductIds
      ? items.filter((i) => options.expectedProductIds!.includes(i.productId))
      : items;
    if (!filtered.length) {
      throw new Error('AppStoreClient: verifyReceipt returned no matching purchases');
    }

    // Pick the item with the latest expirationDate (subscription) or first one (non-subscription).
    const latest = filtered.reduce((acc, cur) => {
      const accExp = Number(acc.expirationDate ?? 0);
      const curExp = Number(cur.expirationDate ?? 0);
      return curExp > accExp ? cur : acc;
    });

    if (latest.bundleId && latest.bundleId !== this.bundleId) {
      throw new Error(
        `AppStoreClient: receipt bundleId mismatch — got ${latest.bundleId}, configured ${this.bundleId}`
      );
    }

    logger.debug('App Store legacy receipt verified', {
      productId: latest.productId,
      transactionId: latest.transactionId,
      expirationDate: latest.expirationDate,
    });

    return {
      transactionId: String(latest.transactionId),
      originalTransactionId: String(latest.originalTransactionId ?? latest.transactionId),
      productId: latest.productId,
      purchaseDate: latest.purchaseDate ? Number(latest.purchaseDate) : undefined,
      expiresDate: latest.expirationDate ? Number(latest.expirationDate) : undefined,
      // legacy receipts carry no client-set UUID
      appAccountToken: undefined,
      environment: this.environment === 'production' ? 'Production' : 'Sandbox',
      bundleId: latest.bundleId ?? this.bundleId,
      webOrderLineItemId: latest.webOrderLineItemId,
    };
  }

  /**
   * App Store Server API — pull latest subscription status by originalTransactionId.
   *
   * Returns the **decoded** latest `signedTransactionInfo` payload from
   * Apple's StatusResponse (re-verified via SignedDataVerifier). Apple
   * returns one transaction per `subscriptionGroupIdentifier`; we always
   * pick the entry whose `originalTransactionId` matches the input.
   *
   * Requires issuer_id / key_id / private_key_pem in PaymentMethod settings.
   */
  public async getSubscriptionStatus(originalTransactionId: string): Promise<AppStoreTransactionPayload | null> {
    if (!this.serverApiCredentials) {
      throw new Error(
        `AppStoreClient: getSubscriptionStatus(${originalTransactionId}) requires issuer_id/key_id/private_key_pem in settings`
      );
    }
    const creds: AppStoreApiCredentials = {
      issuerId: this.serverApiCredentials.issuerId,
      keyId: this.serverApiCredentials.keyId,
      privateKeyPem: this.serverApiCredentials.privateKeyPem,
      bundleId: this.bundleId,
      environment: this.environment,
    };
    const response = await getAllSubscriptionStatuses(originalTransactionId, creds);

    // Apple groups by subscriptionGroupIdentifier — flatten + find the matching original txn.
    const allTransactions = (response.data ?? []).flatMap((group) => group.lastTransactions ?? []);
    const matched = allTransactions.find((t) => t.originalTransactionId === originalTransactionId);
    if (!matched?.signedTransactionInfo) {
      logger.warn('app_store getSubscriptionStatus: no matching transaction found', { originalTransactionId });
      return null;
    }
    return this.verifyJwsTransaction(matched.signedTransactionInfo);
  }

  /** Refund — Apple actually doesn't let third parties refund subscriptions; this is here for symmetry with GooglePlayClient. */
  // eslint-disable-next-line class-methods-use-this, require-await
  public async refundSubscription(originalTransactionId: string): Promise<void> {
    if (!WRITE_ENABLED) {
      throw new Error(
        `refundSubscription(${originalTransactionId}) is gated behind APP_STORE_WRITE_ENABLED env. Note: Apple Server API has no third-party refund endpoint — refunds must be requested by the end user via reportProblem.apple.com or by Apple support.`
      );
    }
    throw new Error('refundSubscription not implementable — Apple does not expose a server-side refund API');
  }

  /** Cancel — same caveat as refund: Apple expects the user to cancel via App Store > Subscriptions. */
  // eslint-disable-next-line class-methods-use-this, require-await
  public async cancelSubscription(originalTransactionId: string): Promise<void> {
    if (!WRITE_ENABLED) {
      throw new Error(
        `cancelSubscription(${originalTransactionId}) is gated behind APP_STORE_WRITE_ENABLED env. Apple does not expose a server-initiated cancel — the user must cancel from their device.`
      );
    }
    throw new Error('cancelSubscription not implementable — Apple does not expose a server-side cancel API');
  }
}
