// Google Play Real-Time Developer Notification webhook receiver.
//
// Pub/Sub Push body:
//   {
//     "message": { "data": "<base64 JSON>", "messageId": "...", "publishTime": "..." },
//     "subscription": "projects/<project>/subscriptions/<sub>"
//   }
//
// Auth: Pub/Sub puts a Google-signed JWT in `Authorization: Bearer <jwt>`.
// We verify the JWT claims here (signature verification is TODO — see verify.ts).

import { Request, Response, Router } from 'express';
import Joi from 'joi';

import handleGooglePlayEvent, { GooglePlayRtdnPayload } from '../../integrations/google-play/handlers';
import { ingestVerifiedGooglePlayPurchase } from '../../integrations/google-play/handlers/subscription';
import { decodePubSubMessage, verifyPubSubJwt } from '../../integrations/google-play/verify';
import logger from '../../libs/logger';
import { authenticate } from '../../libs/security';
import { googlePlayEndpoint } from '../../libs/util';
import { Customer, PaymentMethod } from '../../store/models';

const router = Router();
const userAuth = authenticate<Customer>({ component: false, ensureLogin: true });

const verifyBodySchema = Joi.object<{
  purchaseToken: string;
  subscriptionId: string;
}>({
  purchaseToken: Joi.string().required(),
  subscriptionId: Joi.string().required(),
});

/**
 * Client-initiated verify (aistro-shape).
 * Mobile client POSTs after StoreKit / BillingClient finishes the purchase.
 */
router.post('/verify', userAuth, async (req: Request, res: Response) => {
  try {
    const did = (req as any).user?.did;
    if (!did) {
      res.status(401).json({ error: 'unauthenticated' });
      return;
    }
    const input = await verifyBodySchema.validateAsync(req.body, { stripUnknown: true });

    // Resolve the Google Play PaymentMethod for THIS livemode. Without the
    // livemode filter a testmode request would silently fall through to the
    // production method (and vice versa), and its encrypted credentials may
    // not even decrypt under the current process key.
    const method = await PaymentMethod.findOne({
      where: { type: 'google_play', active: true, livemode: !!req.livemode },
    });
    if (!method) {
      res.status(503).json({ error: 'google_play PaymentMethod not configured' });
      return;
    }
    const client = method.getGooglePlayClient();

    const result = await ingestVerifiedGooglePlayPurchase({
      customerDid: did,
      paymentMethod: method,
      client,
      purchaseToken: input.purchaseToken,
      subscriptionId: input.subscriptionId,
    });

    res.json({
      success: true,
      subscription_id: result.subscription.id,
      isFirstSubscribe: result.isFirstSubscribe,
      active: result.subscription.status === 'active',
      expires_at: result.subscription.current_period_end,
      purchase: {
        order_id: result.purchase.orderId,
        expiry_time_millis: result.purchase.expiryTimeMillis,
        acknowledgement_state: result.purchase.acknowledgementState,
      },
    });
  } catch (err: any) {
    // google-play-billing-validator surfaces some failures via `errorMessage`
    // rather than throwing with a populated message; fall back through both.
    const message = err?.message || err?.errorMessage || (typeof err === 'string' ? err : null) || 'verify failed';
    logger.error('google_play verify failed', {
      message,
      errKeys: err ? Object.keys(err) : [],
      stack: err?.stack,
    });
    res.status(400).json({
      success: false,
      error: { message, raw: err?.errorMessage ?? null },
    });
  }
});

// Restore-side input caps. BillingClient `queryPurchases()` typically returns
// 1-2 active subs per Play account; cap an order of magnitude higher to
// tolerate misbehaving clients without blocking legitimate uses. Each
// purchaseToken is a base64-ish blob (~150-200 chars); 2 KB is plenty.
// Play subscription IDs are bounded to 40 chars by the console — give 256.
const RESTORE_MAX_ITEMS = 50;
const PURCHASE_TOKEN_MAX_LENGTH = 2 * 1024;
const SUBSCRIPTION_ID_MAX_LENGTH = 256;
// Verify pool size. Each restore item triggers a Google Developer API
// purchases.subscriptions.get call + a DB upsert. Bound the pool so an
// authenticated request can't fan out into many concurrent Google calls.
const RESTORE_CONCURRENCY = 5;

const restoreBodySchema = Joi.object<{
  purchases: Array<{ purchaseToken: string; subscriptionId: string }>;
}>({
  purchases: Joi.array()
    .items(
      Joi.object({
        purchaseToken: Joi.string().max(PURCHASE_TOKEN_MAX_LENGTH).required(),
        subscriptionId: Joi.string().max(SUBSCRIPTION_ID_MAX_LENGTH).required(),
      })
    )
    .min(1)
    .max(RESTORE_MAX_ITEMS)
    .required(),
});

/**
 * Restore purchases for Google Play.
 *
 * BillingClient on Android exposes `queryPurchases()` which returns active
 * purchases from the Play cache. The mobile client iterates that list and
 * posts each {purchaseToken, subscriptionId} pair here. We re-verify and
 * either return the existing local Subscription or create one. Partial
 * success is reported per item.
 */
router.post('/restore', userAuth, async (req: Request, res: Response) => {
  try {
    const did = (req as any).user?.did;
    if (!did) {
      res.status(401).json({ error: 'unauthenticated' });
      return;
    }
    const input = await restoreBodySchema.validateAsync(req.body, { stripUnknown: true });

    const method = await PaymentMethod.findOne({
      where: { type: 'google_play', active: true, livemode: !!req.livemode },
    });
    if (!method) {
      res.status(503).json({ error: 'google_play PaymentMethod not configured' });
      return;
    }
    const client = method.getGooglePlayClient();

    // Dedupe by purchaseToken — a single token is unique to one Google
    // Play purchase, so duplicates in the request would otherwise double-
    // call Google's verifier and re-upsert the same Subscription row.
    const seen = new Set<string>();
    const purchases = input.purchases.filter((p) => {
      if (seen.has(p.purchaseToken)) return false;
      seen.add(p.purchaseToken);
      return true;
    });

    // Bounded concurrency: process in fixed-size batches. Each item hits
    // Google's Developer API + at least one DB write; Promise.all over
    // an arbitrary list lets a single authenticated request fan out into
    // many concurrent Google calls.
    type ItemResult =
      | {
          ok: true;
          subscription_id: string;
          isFirstSubscribe: boolean;
          product_id: string;
        }
      | { ok: false; error: string; product_id: string };
    const results: ItemResult[] = [];
    for (let i = 0; i < purchases.length; i += RESTORE_CONCURRENCY) {
      const batch = purchases.slice(i, i + RESTORE_CONCURRENCY);
      // eslint-disable-next-line no-await-in-loop -- intentional: batches must complete sequentially to bound concurrency
      const batchResults = await Promise.all(
        batch.map(async (p): Promise<ItemResult> => {
          try {
            const r = await ingestVerifiedGooglePlayPurchase({
              customerDid: did,
              paymentMethod: method,
              client,
              purchaseToken: p.purchaseToken,
              subscriptionId: p.subscriptionId,
            });
            return {
              ok: true,
              subscription_id: r.subscription.id,
              isFirstSubscribe: r.isFirstSubscribe,
              product_id: p.subscriptionId,
            };
          } catch (err: any) {
            return {
              ok: false,
              error: err?.message ?? 'restore failed',
              product_id: p.subscriptionId,
            };
          }
        })
      );
      results.push(...batchResults);
    }

    res.json({
      restored: results.filter((r) => r.ok),
      errors: results.filter((r) => !r.ok),
    });
  } catch (err: any) {
    logger.error('google_play restore failed', { error: err?.message, stack: err?.stack });
    res.status(400).json({ error: err?.message ?? 'restore failed' });
  }
});

// In-process dedup of recently-seen Pub/Sub messageIds. Pub/Sub guarantees the
// same messageId on retries, so if we've already started handling this exact
// message we can skip duplicate delivery (Google retries even on 2xx if its
// timer expires before our response). Map<messageId, expiryEpochMs>; we cap
// the map to avoid unbounded growth.
const seenMessageIds = new Map<string, number>();
const MESSAGE_DEDUP_TTL_MS = 10 * 60 * 1000; // 10 min — Pub/Sub retries within
// ack deadline (default 10s) but
// can also redeliver on cron, so
// keep a comfortable window.
const MESSAGE_DEDUP_MAX_SIZE = 1000;

/** True if this messageId was already processed SUCCESSFULLY within the TTL. */
function wasHandled(messageId: string): boolean {
  const exp = seenMessageIds.get(messageId);
  return !!exp && exp > Date.now();
}

/**
 * Mark a messageId as successfully handled. Called ONLY after processing
 * succeeds — so a failed/transient attempt is NOT deduped away and Pub/Sub's
 * retry is allowed to run (PR #1381 review P1). NOTE: in-memory, so it does not
 * survive Worker restarts — durable idempotency is a follow-up.
 */
function markHandled(messageId: string): void {
  const now = Date.now();
  if (seenMessageIds.size > MESSAGE_DEDUP_MAX_SIZE) {
    for (const [id, exp] of seenMessageIds) {
      if (exp < now) seenMessageIds.delete(id);
    }
  }
  seenMessageIds.set(messageId, now + MESSAGE_DEDUP_TTL_MS);
}

router.post('/webhook', async (req: Request, res: Response) => {
  const expectedEmail = process.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT;
  // Fail CLOSED: in production the push service account MUST be configured. A
  // sandbox/test bypass has to be explicit (PR #1381 review P1).
  const allowUnverifiedSender =
    process.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER === 'true' || process.env.NODE_ENV === 'test';

  // --- Phase 1: authenticate + select. Failures here are rejections / not-for-us,
  //     NOT processing failures. ---
  let payload: GooglePlayRtdnPayload;
  let client: ReturnType<PaymentMethod['getGooglePlayClient']>;
  let messageId: string | undefined;
  try {
    if (!expectedEmail && !allowUnverifiedSender) {
      logger.error(
        'google_play webhook refusing: GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT unset ' +
          '(set GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER=true only for sandbox)'
      );
      res.status(403).json({ error: 'sender verification not configured' });
      return;
    }

    const authHeader = req.get('authorization') || req.get('Authorization');
    if (authHeader) {
      const token = authHeader.replace(/^Bearer\s+/i, '');
      await verifyPubSubJwt(token, { expectedAudience: googlePlayEndpoint(), expectedEmail });
    } else if (!allowUnverifiedSender) {
      logger.warn('google_play webhook missing Authorization header');
      res.status(401).json({ error: 'missing authorization' });
      return;
    }

    messageId = req.body?.message?.messageId;
    // Skip only messages we already handled SUCCESSFULLY (mark happens post-success).
    if (messageId && wasHandled(messageId)) {
      logger.info('google_play webhook: duplicate Pub/Sub messageId, skipping', { messageId });
      res.json({ deduped: true });
      return;
    }

    payload = decodePubSubMessage<GooglePlayRtdnPayload>(req.body);

    const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
    const method = methods.find((m) => {
      const settings = PaymentMethod.decryptSettings(m.settings);
      return settings.google_play?.package_name === payload.packageName;
    });
    if (!method) {
      logger.warn('google_play webhook: no matching PaymentMethod for packageName', {
        packageName: payload.packageName,
      });
      // Not for us → ack so Pub/Sub doesn't retry a misconfigured topic forever.
      res.json({ skipped: true });
      return;
    }
    client = method.getGooglePlayClient();
  } catch (err: any) {
    // Auth / decode / selection failure → forged or malformed; reject.
    logger.warn('google_play webhook: auth/decode failed', { error: err?.message });
    res.status(401).json({ error: 'unauthorized' });
    return;
  }

  // --- Phase 2: process the verified event. Failure here is transient → 5xx so
  //     Pub/Sub retries; mark the messageId handled ONLY after success. ---
  try {
    await handleGooglePlayEvent(payload, client);
    if (messageId) markHandled(messageId);
    res.json({ received: true });
  } catch (err: any) {
    logger.error('google_play webhook processing failed — will retry', { error: err?.message, stack: err?.stack });
    res.status(500).json({ error: err?.message ?? 'processing failed' });
  }
});

export default router;
