import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { NotificationTypeV2, Subtype } from "@apple/app-store-server-library";
import { db, isDatabaseAvailable, schema } from "@/lib/db";
import {
  APPLE_PRODUCT_TO_PLAN,
  userIdFromAppAccountToken,
  verifyAppleNotification,
  verifyAppleTransaction,
} from "@/lib/billing/appleIAP";
import { MAX_JSON_BODY_BYTES } from "@/lib/limits";
import { guardBodySize, readJsonSized } from "@/lib/http/requestSize";

export const runtime = "nodejs";

/** Generic client-safe message — internal details only ever go to the logs. */
const GENERIC_WEBHOOK_ERROR = "Webhook processing failed";

interface AppleWebhookBody {
  signedPayload?: string;
}

/**
 * `POST /api/webhooks/apple` — App Store Server Notifications V2. The iOS
 * counterpart to `/api/webhooks/stripe`: reconciles a StoreKit purchase (see
 * `app/ios/ScanReceipts/Features/Settings/StoreKitPurchaseService.swift`)
 * into `users.plan`/`expiresAt`/`cancelAtPeriodEnd`, the exact same fields
 * the Stripe webhook writes — a Pro account looks identical to the rest of
 * the app regardless of which store sold it.
 *
 * Set this URL in App Store Connect → your app → App Information → App
 * Store Server Notifications, once the app exists there:
 * `https://<domain>/api/webhooks/apple`.
 */
export async function POST(req: NextRequest) {
  try {
    // Reject oversized payloads from the Content-Length header before the
    // body is buffered — same discipline as every other body-reading route.
    const tooLarge = guardBodySize(req, MAX_JSON_BODY_BYTES);
    if (tooLarge) return tooLarge;

    const parsed = await readJsonSized<AppleWebhookBody>(req, MAX_JSON_BODY_BYTES);
    if (!parsed.ok) return parsed.response;
    const signedPayload = parsed.body?.signedPayload;
    if (!signedPayload) {
      return NextResponse.json({ error: "Missing signedPayload" }, { status: 400 });
    }

    // Sole entry gate: nothing below runs without a signature that verifies
    // against Apple's own certificate chain (full x5c validation up to
    // Apple's root CA — see lib/billing/appleIAP.ts). Unlike Stripe's HMAC
    // check, this is NOT hand-rolled; it's Apple's own official library.
    let notification;
    try {
      notification = await verifyAppleNotification(signedPayload);
    } catch (err) {
      console.error("Apple webhook signature verification failed:", err);
      return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
    }

    const { payload, environment } = notification;
    const signedTransactionInfo = payload.data?.signedTransactionInfo;

    if (!signedTransactionInfo) {
      // No transaction attached (TEST pings, EXTERNAL_PURCHASE_TOKEN, a
      // renewal-date-extension `summary` batch, ...) — nothing to reconcile.
      return NextResponse.json({ received: true });
    }

    let transaction;
    try {
      transaction = await verifyAppleTransaction(signedTransactionInfo, environment);
    } catch (err) {
      console.error("Apple webhook transaction verification failed:", err);
      return NextResponse.json({ error: "Invalid transaction signature" }, { status: 400 });
    }

    const plan = transaction.productId ? APPLE_PRODUCT_TO_PLAN[transaction.productId] : undefined;
    if (!plan) {
      console.warn("Apple webhook: unrecognised productId, ignoring:", transaction.productId);
      return NextResponse.json({ received: true });
    }

    const userId = userIdFromAppAccountToken(transaction.appAccountToken);
    if (!userId) {
      // Either an old client build that predates appAccountToken, or a
      // purchase made while genuinely signed out — nothing we can attribute
      // this to. Acknowledge so Apple doesn't retry forever; log so it's
      // visible that some purchase couldn't be reconciled.
      console.warn("Apple webhook: no resolvable appAccountToken, ignoring:", {
        productId: transaction.productId,
        originalTransactionId: transaction.originalTransactionId,
      });
      return NextResponse.json({ received: true });
    }

    const hasDb = await isDatabaseAvailable().catch(() => false);
    if (!hasDb) return NextResponse.json({ received: true });

    const userRows = await db
      .select({ id: schema.users.id })
      .from(schema.users)
      .where(eq(schema.users.id, userId))
      .limit(1);
    if (userRows.length === 0) {
      console.warn("Apple webhook: appAccountToken resolved to an unknown user, ignoring:", userId);
      return NextResponse.json({ received: true });
    }

    const now = new Date();
    const grant = (expiresAt: Date | null, cancelAtPeriodEnd: boolean) =>
      db
        .update(schema.users)
        .set({ plan, expiresAt, cancelAtPeriodEnd, updatedAt: now })
        .where(eq(schema.users.id, userId));
    const revoke = () =>
      db
        .update(schema.users)
        .set({ plan: "free", expiresAt: null, cancelAtPeriodEnd: false, updatedAt: now })
        .where(eq(schema.users.id, userId));
    const setCancelAtPeriodEnd = (value: boolean) =>
      db.update(schema.users).set({ cancelAtPeriodEnd: value, updatedAt: now }).where(eq(schema.users.id, userId));

    try {
      switch (payload.notificationType) {
        // Subscription became (or stayed) active — grant/extend access
        // through the transaction's own expiresDate. RENEWAL_EXTENDED /
        // RENEWAL_EXTENSION are Apple support/goodwill date pushes; treated
        // the same as a renewal since the effect on the customer is identical.
        case NotificationTypeV2.SUBSCRIBED:
        case NotificationTypeV2.DID_RENEW:
        case NotificationTypeV2.OFFER_REDEEMED:
        case NotificationTypeV2.RENEWAL_EXTENDED:
        case NotificationTypeV2.RENEWAL_EXTENSION:
          await grant(transaction.expiresDate ? new Date(transaction.expiresDate) : null, false);
          break;

        // Non-consumable (lifetime) purchase — never expires.
        case NotificationTypeV2.ONE_TIME_CHARGE:
          await grant(null, false);
          break;

        // User toggled auto-renew — mirrors the web's "cancel at period end"
        // semantics: Pro access continues until expiresAt either way, this
        // only flips whether it will renew past that date.
        case NotificationTypeV2.DID_CHANGE_RENEWAL_STATUS:
          if (payload.subtype === Subtype.AUTO_RENEW_DISABLED) {
            await setCancelAtPeriodEnd(true);
          } else if (payload.subtype === Subtype.AUTO_RENEW_ENABLED) {
            await setCancelAtPeriodEnd(false);
          }
          break;

        // The subscription has genuinely lapsed — the authoritative "it's
        // over" signal, equivalent to Stripe's customer.subscription.deleted.
        case NotificationTypeV2.EXPIRED:
        case NotificationTypeV2.GRACE_PERIOD_EXPIRED:
          await revoke();
          break;

        // Money was actually returned (or Family Sharing access pulled) —
        // downgrade immediately; there is no "period end" left to honour.
        case NotificationTypeV2.REFUND:
        case NotificationTypeV2.REVOKE:
          await revoke();
          break;

        // Mirrors the Stripe webhook's invoice.payment_failed handling:
        // Apple auto-retries a failed renewal for a while (billing grace
        // period) before giving up, so downgrading on the FIRST failure
        // would strand a paying customer mid-retry over what's often a
        // transient card decline. Wait for EXPIRED/GRACE_PERIOD_EXPIRED,
        // which fire once retries are actually exhausted.
        case NotificationTypeV2.DID_FAIL_TO_RENEW:
          break;

        default:
          // Whitelisted no-op events (DID_CHANGE_RENEWAL_PREF, PRICE_INCREASE,
          // CONSUMPTION_REQUEST, TEST, METADATA_UPDATE, MIGRATION,
          // PRICE_CHANGE, RESCIND_CONSENT, EXTERNAL_PURCHASE_TOKEN, ...) —
          // acknowledged without a database write.
          break;
      }
    } catch (dbErr) {
      console.error("Apple webhook: database error while reconciling plan:", dbErr);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    // Internal details go to the logs only — never back to the client.
    console.error("Apple Webhook Error:", error);
    return NextResponse.json({ error: GENERIC_WEBHOOK_ERROR }, { status: 500 });
  }
}
