import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";
import { logger } from "@/lib/logger";
import { sendDay0Welcome, sendDunningEmail, sendSubscriptionCancelledRecovery } from "@/lib/email-sequences";

export const runtime = "nodejs";

// ---------------------------------------------------------------------------
// Idempotency: in-memory set of processed Stripe event IDs.
// Prevents double-processing when Stripe retries a delivery we already handled.
//
// NOTE: This works for a single-process deployment. For multi-instance
// production, replace with a Prisma-backed StripeEvent table:
//
//   model StripeEvent {
//     id        String   @id               // Stripe event ID (evt_...)
//     type      String
//     processed Boolean  @default(true)
//     createdAt DateTime @default(now())
//   }
//
// Then check/insert with a unique constraint instead of this Set.
// ---------------------------------------------------------------------------
const MAX_PROCESSED_EVENTS = 10_000;
const processedEventIds = new Set<string>();

function markEventProcessed(eventId: string): void {
  // Evict oldest entries when the set grows too large to prevent memory leaks.
  // Sets iterate in insertion order, so the first value is the oldest.
  if (processedEventIds.size >= MAX_PROCESSED_EVENTS) {
    const oldest = processedEventIds.values().next().value;
    if (oldest) processedEventIds.delete(oldest);
  }
  processedEventIds.add(eventId);
}

function isEventAlreadyProcessed(eventId: string): boolean {
  return processedEventIds.has(eventId);
}

// ---------------------------------------------------------------------------
// Event Handlers
// ---------------------------------------------------------------------------

/**
 * One-time payment: marketplace credits purchase.
 * Session mode is "payment"; metadata.type === "marketplace_credits", metadata.amount_cents, metadata.userId.
 */
async function handleCheckoutCompletedCredits(
  session: Stripe.Checkout.Session,
): Promise<void> {
  const type = session.metadata?.type;
  if (type !== "marketplace_credits") return;

  const userId = session.metadata?.userId;
  const amountStr = session.metadata?.amount_cents;
  if (!userId || !amountStr) {
    logger.error(
      { action: "stripe_webhook", event: "checkout_completed_credits" },
      "Missing metadata userId or amount_cents",
    );
    return;
  }
  const amountCents = parseInt(amountStr, 10);
  if (!Number.isInteger(amountCents) || amountCents <= 0) {
    logger.error(
      { action: "stripe_webhook", event: "checkout_completed_credits", amountStr },
      "Invalid amount_cents in metadata",
    );
    return;
  }

  const user = await db.user.findUnique({
    where: { id: userId },
    select: { id: true, marketplaceCreditsCents: true },
  });
  if (!user) {
    logger.error(
      { action: "stripe_webhook", event: "checkout_completed_credits", userId },
      "User not found for credits purchase",
    );
    return;
  }

  await db.user.update({
    where: { id: userId },
    data: {
      marketplaceCreditsCents: { increment: amountCents },
    },
  });
  logger.info(
    {
      action: "stripe_webhook",
      event: "checkout_completed_credits",
      userId,
      amountCents,
      newBalance: user.marketplaceCreditsCents + amountCents,
    },
    "Added marketplace credits for user",
  );
}

async function handleCheckoutCompleted(
  session: Stripe.Checkout.Session,
): Promise<void> {
  if (!stripe) return;

  // One-time payment: marketplace credits
  if (session.mode === "payment" && session.metadata?.type === "marketplace_credits") {
    await handleCheckoutCompletedCredits(session);
    return;
  }

  const subscription = await stripe.subscriptions.retrieve(
    session.subscription as string,
  );

  const userId =
    session.metadata?.userId ??
    (
      await db.user.findUnique({
        where: { stripeCustomerId: session.customer as string },
        select: { id: true },
      })
    )?.id;

  if (!userId) {
    logger.error({ action: 'stripe_webhook', event: 'checkout_completed' }, 'No user found for checkout session');
    return;
  }

  const priceId = subscription.items.data[0]?.price.id;

  const plan = getPlanFromPriceId(priceId);

  const updatedUser = await db.user.update({
    where: { id: userId },
    data: {
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: session.customer as string,
      stripePriceId: priceId,
      stripeCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
      plan,
    },
    select: { email: true, name: true },
  });

  // Track subscription start event (safe to skip if UserEvent migration not yet applied)
  try {
    await db.userEvent.create({
      data: {
        userId,
        event: "subscription_started",
        metadata: JSON.stringify({ plan, priceId }),
      },
    });
  } catch (err) {
    logger.warn({ err, action: "stripe_webhook" }, "userEvent.create skipped — run prisma migrate dev");
  }

  logger.info({ action: 'stripe_webhook', event: 'checkout_completed', userId, plan }, 'Activated subscription for user');

  // Send Day 0 onboarding welcome email (non-blocking)
  sendDay0Welcome(
    updatedUser.email,
    updatedUser.name ?? "there",
    plan.charAt(0).toUpperCase() + plan.slice(1),
  ).catch((err) =>
    logger.error({ err, action: "stripe_webhook", event: "day0_welcome" }, "Failed to send Day 0 welcome"),
  );
}

async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription,
): Promise<void> {
  const user = await db.user.findFirst({
    where: { stripeSubscriptionId: subscription.id },
    select: { id: true, plan: true },
  });

  if (!user) {
    logger.error({ action: 'stripe_webhook', event: 'subscription_updated' }, 'No user found for subscription');
    return;
  }

  const priceId = subscription.items.data[0]?.price.id;

  // Reflect Stripe subscription status in the user's plan.
  // If the subscription is no longer active, downgrade accordingly.
  let plan: string;
  if (
    subscription.status === "active" ||
    subscription.status === "trialing"
  ) {
    plan = getPlanFromPriceId(priceId);
  } else if (subscription.status === "past_due") {
    // Keep the tier visible but flag the account as past_due
    plan = "past_due";
  } else if (
    subscription.status === "canceled" ||
    subscription.status === "unpaid"
  ) {
    plan = "free";
  } else {
    // incomplete, incomplete_expired, paused — keep current plan
    plan = user.plan;
  }

  await db.user.update({
    where: { id: user.id },
    data: {
      stripePriceId: priceId,
      stripeCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
      plan,
    },
  });

  // NRR tracking: log plan tier changes (expansion/contraction) for NRR calculation.
  // NRR = (MRR_start + expansion - contraction - churn) / MRR_start.
  // Expansion: free→pro, pro→team, team→enterprise.
  // Contraction: enterprise→team, team→pro, pro→free.
  const tierOrder: Record<string, number> = { free: 0, pro: 1, team: 2, enterprise: 3 };
  const oldTier = tierOrder[user.plan] ?? 0;
  const newTier = tierOrder[plan] ?? 0;
  if (oldTier !== newTier && plan !== "past_due") {
    const changeType = newTier > oldTier ? "plan_upgraded" : "plan_downgraded";
    try {
      await db.userEvent.create({
        data: {
          userId: user.id,
          event: changeType,
          metadata: JSON.stringify({ from: user.plan, to: plan }),
        },
      });
    } catch (err) {
      logger.warn({ err, action: "stripe_webhook" }, "userEvent.create skipped — run prisma migrate dev");
    }
    logger.info(
      { action: "stripe_webhook", event: changeType, userId: user.id, from: user.plan, to: plan },
      `User ${changeType}: ${user.plan} → ${plan}`,
    );
  }

  logger.info({ action: 'stripe_webhook', event: 'subscription_updated', userId: user.id, status: subscription.status, plan }, 'Subscription updated for user');
}

async function handleSubscriptionDeleted(
  subscription: Stripe.Subscription,
): Promise<void> {
  const user = await db.user.findFirst({
    where: { stripeSubscriptionId: subscription.id },
    select: { id: true, email: true, name: true },
  });

  if (!user) {
    logger.error({ action: 'stripe_webhook', event: 'subscription_deleted' }, 'No user found for deleted subscription');
    return;
  }

  await db.user.update({
    where: { id: user.id },
    data: {
      stripeSubscriptionId: null,
      stripePriceId: null,
      stripeCurrentPeriodEnd: null,
      plan: "free",
    },
  });

  logger.info({ action: 'stripe_webhook', event: 'subscription_deleted', userId: user.id }, 'Subscription deleted, user deactivated');

  // Send subscription cancelled recovery email (non-blocking)
  if (user.email) {
    sendSubscriptionCancelledRecovery(
      user.email,
      user.name ?? user.email.split("@")[0],
    ).catch((err) =>
      logger.error({ err, action: "stripe_webhook", event: "cancellation_recovery" }, "Failed to send recovery email"),
    );
  }
}

async function handleInvoicePaymentFailed(
  invoice: Stripe.Invoice,
): Promise<void> {
  // Stripe v20: subscription moved from invoice.subscription to
  // invoice.parent.subscription_details.subscription
  const subRef = invoice.parent?.subscription_details?.subscription;
  const subscriptionId =
    typeof subRef === "string" ? subRef : subRef?.id ?? null;

  if (!subscriptionId) {
    logger.warn({ action: 'stripe_webhook', event: 'invoice_payment_failed' }, 'No subscription ID on invoice');
    return;
  }

  const user = await db.user.findFirst({
    where: { stripeSubscriptionId: subscriptionId },
    select: { id: true, email: true, plan: true },
  });

  if (!user) {
    logger.error({ action: 'stripe_webhook', event: 'invoice_payment_failed' }, 'No user found for invoice subscription');
    return;
  }

  logger.warn({ action: 'stripe_webhook', event: 'invoice_payment_failed', userId: user.id, plan: user.plan, invoiceId: invoice.id, amountDue: invoice.amount_due, attemptCount: invoice.attempt_count }, 'Invoice payment failed');

  // Flag the account as past_due but don't immediately downgrade.
  // This gives the user time to update their payment method.
  // Stripe's own dunning/retry logic will eventually cancel the subscription
  // if payment continues to fail, which triggers customer.subscription.deleted.
  await db.user.update({
    where: { id: user.id },
    data: { plan: "past_due" },
  });

  // Send dunning recovery email (non-blocking)
  // Playbook: ~48% of involuntary churn comes from failed payments; 40-60% recoverable with dunning.
  if (user.email) {
    sendDunningEmail(
      user.email,
      user.email.split("@")[0],
      invoice.attempt_count ?? 1,
    ).catch((err) =>
      logger.error({ err, action: "stripe_webhook", event: "dunning_email" }, "Failed to send dunning email"),
    );
  }
}

/**
 * Connect: account.updated — when Express onboarding completes (or account is updated).
 * Find user by stripeConnectAccountId and mark onboarding complete.
 */
async function handleConnectAccountUpdated(
  accountId: string,
): Promise<void> {
  const updated = await db.user.updateMany({
    where: { stripeConnectAccountId: accountId },
    data: { stripeConnectOnboardingComplete: true },
  });

  if (updated.count > 0) {
    logger.info(
      { action: "stripe_webhook", event: "account.updated", accountId },
      "Marked Connect onboarding complete for user",
    );
  }
}

/**
 * Connect: payout.paid — when a payout to a connected account completes.
 * Logged for audit trail. The actual billing event marking happens at transfer
 * time (POST /api/stripe/connect/payout), so this is informational only.
 */
async function handlePayoutPaid(
  payout: Stripe.Payout,
  accountId: string | undefined,
): Promise<void> {
  logger.info(
    {
      action: "stripe_webhook",
      event: "payout.paid",
      payoutId: payout.id,
      amount: payout.amount,
      currency: payout.currency,
      accountId: accountId ?? "unknown",
    },
    "Payout completed for connected account",
  );
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// Stripe SDK v20 removed `current_period_end` from TypeScript types but the
// field is still present in API responses. Extract it via the raw response
// until the codebase migrates to the invoice-based period lookup.
interface SubscriptionWithPeriod extends Stripe.Subscription {
  current_period_end?: number;
}

function getSubscriptionPeriodEnd(
  subscription: Stripe.Subscription | SubscriptionWithPeriod,
): Date | null {
  const raw = subscription as SubscriptionWithPeriod;
  if (raw.current_period_end) {
    return new Date(raw.current_period_end * 1000);
  }
  return null;
}

function getPlanFromPriceId(priceId: string | undefined): string {
  if (!priceId) return "free";

  const proPriceId =
    process.env.STRIPE_PRO_PRICE_ID || "price_pro_placeholder";
  const teamPriceId =
    process.env.STRIPE_TEAM_PRICE_ID || "price_team_placeholder";
  const enterprisePriceId =
    process.env.STRIPE_ENTERPRISE_PRICE_ID || "price_enterprise_placeholder";

  if (priceId === proPriceId) return "pro";
  if (priceId === teamPriceId) return "team";
  if (priceId === enterprisePriceId) return "enterprise";

  return "free";
}

// ---------------------------------------------------------------------------
// POST handler — Stripe sends webhook events here
// ---------------------------------------------------------------------------

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  // Missing signature is a client error — return 400 so Stripe does NOT retry
  if (!signature) {
    return NextResponse.json(
      { error: "Missing stripe-signature header" },
      { status: 400 },
    );
  }

  if (!stripe) {
    // Stripe SDK not configured — return 503 so Stripe retries later
    logger.error({ action: 'stripe_webhook' }, 'Stripe client not configured (missing STRIPE_SECRET_KEY)');
    return NextResponse.json(
      { error: "Stripe is not configured" },
      { status: 503 },
    );
  }

  // -----------------------------------------------------------------------
  // Step 1: Verify webhook signature (platform or Connect endpoint)
  // -----------------------------------------------------------------------
  let event: Stripe.Event | undefined;
  const platformSecret = process.env.STRIPE_WEBHOOK_SECRET;
  const connectSecret = process.env.STRIPE_CONNECT_WEBHOOK_SECRET;
  const secrets = [
    ...(platformSecret ? [platformSecret] : []),
    ...(connectSecret ? [connectSecret] : []),
  ];
  let lastError: unknown;
  for (const secret of secrets) {
    try {
      event = stripe.webhooks.constructEvent(body, signature, secret);
      break;
    } catch (err) {
      lastError = err;
    }
  }
  if (!event) {
    logger.error({ err: lastError, action: "stripe_webhook" }, "Signature verification failed");
    return NextResponse.json(
      { error: "Invalid signature" },
      { status: 400 },
    );
  }

  // -----------------------------------------------------------------------
  // Step 2: Idempotency check — skip already-processed events
  // -----------------------------------------------------------------------
  if (isEventAlreadyProcessed(event.id)) {
    logger.info({ action: 'stripe_webhook', eventId: event.id, eventType: event.type }, 'Skipping already-processed event');
    return NextResponse.json({ received: true });
  }

  // -----------------------------------------------------------------------
  // Step 3: Dispatch to handler
  // Always return 200 after signature verification succeeds.
  // Processing errors are logged but must NOT cause Stripe to retry,
  // since retrying would just hit the same error again.
  // -----------------------------------------------------------------------
  try {
    switch (event.type) {
      case "checkout.session.completed":
        await handleCheckoutCompleted(
          event.data.object as Stripe.Checkout.Session,
        );
        break;
      case "customer.subscription.updated":
        await handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription,
        );
        break;
      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(
          event.data.object as Stripe.Subscription,
        );
        break;
      case "invoice.payment_failed":
        await handleInvoicePaymentFailed(
          event.data.object as Stripe.Invoice,
        );
        break;
      case "account.updated": {
        // Connect event: event.account is the connected account id
        const accountId = (event as Stripe.Event & { account?: string }).account;
        if (accountId) {
          await handleConnectAccountUpdated(accountId);
        }
        break;
      }
      case "payout.paid": {
        const payoutAccountId = (event as Stripe.Event & { account?: string }).account;
        await handlePayoutPaid(
          event.data.object as Stripe.Payout,
          payoutAccountId,
        );
        break;
      }
      default:
        logger.info({ action: 'stripe_webhook', eventType: event.type }, 'Unhandled event type');
    }
  } catch (error) {
    // Log the error but still return 200 — Stripe should NOT retry on
    // our processing failures. The issue needs to be fixed on our side.
    logger.error({ err: error, action: 'stripe_webhook', eventId: event.id, eventType: event.type }, 'Error processing event');
  }

  // Mark as processed AFTER handling (even if handler threw) to avoid
  // re-processing on Stripe's automatic retry of the same event ID.
  markEventProcessed(event.id);

  return NextResponse.json({ received: true });
}
