/**
 * Group Reservation workflows - FDS §7 (individual participant reservations).
 *
 * A reservation represents a participant's slot in a group deal.
 * Payment is held (J2 authorization) at reservation time and captured (J3)
 * only when the group deal succeeds.
 *
 * All DB access goes through src/server/db/queries/* helpers.
 * No raw SQL. All inputs are zod-validated at the API boundary.
 */

import { eq } from 'drizzle-orm';
import type { DrizzleClient } from '@/server/db/client.js';
import { getDefaultSku } from '@/server/domain/variants/read.js';
import { encrypt } from '@/server/db/crypto.js';
import { env } from '@/server/env.js';
import { setReservationMockScenario } from '@/server/db/queries/mock-payment-events.js';
import * as dealQueries from '@/server/db/queries/deals.js';
import * as groupDealQueries from '@/server/db/queries/group-deals.js';
import * as groupReservationQueries from '@/server/db/queries/group-reservations.js';
import * as groupTierQueries from '@/server/db/queries/group-tiers.js';
import * as groupWaitlistQueries from '@/server/db/queries/group-waitlist.js';
import * as vendorQueries from '@/server/db/queries/vendors.js';
import { paymentMethods, type groupDeals, groupWaitlist } from '@/server/db/schema.js';
import type { VendorAccount } from '@/server/payments/provider.js';
import { enqueueOutbox } from '@/server/queues/outbox-producer.js';
import { insertOutboxRow } from '@/server/db/queries/outbox.js';
import type { GroupDealDeps } from './group-deal.js';
import { recordThresholdMet, recordThresholdLost } from './group-deal.js';
import { decide } from '@/server/domain/group-reservation/machine.js';
import { splitCommission } from '@/lib/money.js';
import type { GroupReservationContext } from '@/server/domain/group-reservation/machine.js';
import { applyEffects } from '@/server/domain/group-reservation/apply-effects.js';
import type { GroupState, CancellationPolicy } from '@/server/domain/group-reservation/events.js';

// ─── Re-export deps type ──────────────────────────────────────────────────────

export interface GroupReservationDeps extends GroupDealDeps {
  auth?: { PII_KEY: string };
  /** Mock payment scenario from request cookie — null under Stripe. */
  mockScenario?: string | null;
}

// ─── Typed error ──────────────────────────────────────────────────────────────

export class GroupReservationError extends Error {
  constructor(
    public readonly code: string,
    message: string,
  ) {
    super(message);
    this.name = 'GroupReservationError';
  }
}

// ─── Outbox helper ────────────────────────────────────────────────────────────

async function enqueueEvent(
  db: DrizzleClient,
  aggregateId: string,
  eventType: string,
  payload: Record<string, unknown>,
) {
  const { id: outboxId } = await insertOutboxRow(db, {
    aggregateType: 'group_reservation',
    aggregateId,
    eventType,
    payload,
  });
  await enqueueOutbox(outboxId);
}

// ─── Price determination helper ───────────────────────────────────────────────

async function determineUnitPrice(
  db: DrizzleClient,
  groupDeal: typeof groupDeals.$inferSelect,
  baseDiscountedPrice: string,
): Promise<string> {
  // Early-bird pricing: if earlyBirdEnabled and current count < earlyBirdSlots
  if (
    groupDeal.earlyBirdEnabled &&
    groupDeal.earlyBirdSlots != null &&
    groupDeal.earlyBirdDiscountPercent != null &&
    groupDeal.currentReservationCount < groupDeal.earlyBirdSlots
  ) {
    // Apply earlyBirdDiscountPercent to the base price
    const earlyBirdMultiplier = 1 - groupDeal.earlyBirdDiscountPercent / 100;
    return (parseFloat(baseDiscountedPrice) * earlyBirdMultiplier).toFixed(2);
  }

  // Tiered pricing: find applicable tier based on CURRENT participant count
  if (groupDeal.tieredPricingEnabled) {
    const applicableTier = await groupTierQueries.getApplicableTier(
      db,
      groupDeal.id,
      groupDeal.currentReservationCount + 1, // +1 because we're adding this participant
    );
    if (applicableTier) {
      return applicableTier.pricePerUnit;
    }
  }

  // Default: base discounted price from parent deal
  return baseDiscountedPrice;
}

// ─── createGroupReservation (registered user) ─────────────────────────────────

export interface CreateGroupReservationInput {
  groupDealId: string;
  userId: string;
  paymentMethodId: string;
  quantity: number;
  idempotencyKey: string;
}

export async function createGroupReservation(
  deps: GroupReservationDeps,
  input: CreateGroupReservationInput,
) {
  const { db } = deps;

  // 1. Load and validate group deal
  const groupDeal = await groupDealQueries.findById(db, input.groupDealId);
  if (!groupDeal) {
    throw new GroupReservationError('GROUP_DEAL_NOT_FOUND', 'Group deal not found');
  }

  const deal = await dealQueries.findById(db, groupDeal.dealId);
  if (!deal) {
    throw new GroupReservationError('DEAL_NOT_FOUND', 'Parent deal not found');
  }

  if (deal.dealState !== 'ACTIVE') {
    throw new GroupReservationError('DEAL_NOT_ACTIVE', `Deal is not active: ${deal.dealState}`);
  }

  if (groupDeal.groupState !== 'COLLECTING' && groupDeal.groupState !== 'THRESHOLD_MET') {
    throw new GroupReservationError(
      'GROUP_NOT_ACCEPTING',
      `Group deal is not accepting reservations: ${groupDeal.groupState}`,
    );
  }

  if (groupDeal.currentReservationCount >= groupDeal.maxGroupSize) {
    throw new GroupReservationError('GROUP_FULL', 'Group deal is full');
  }

  // 2. Check per-customer limit
  const existingCount = await groupReservationQueries.countByUserAndGroupDeal(
    db,
    input.userId,
    input.groupDealId,
  );

  const totalAfterReservation = existingCount + input.quantity;
  if (totalAfterReservation > groupDeal.perCustomerLimit) {
    throw new GroupReservationError(
      'PER_CUSTOMER_LIMIT_EXCEEDED',
      `You can reserve at most ${groupDeal.perCustomerLimit} unit(s) for this group deal`,
    );
  }

  // 3. Load payment method
  const [pm] = await db
    .select()
    .from(paymentMethods)
    .where(eq(paymentMethods.id, input.paymentMethodId))
    .limit(1);

  if (!pm) {
    throw new GroupReservationError('PAYMENT_METHOD_NOT_FOUND', 'Payment method not found');
  }

  // 4. Determine unit price
  const defaultSku = await getDefaultSku(db, deal.id);
  const unitPrice = await determineUnitPrice(db, groupDeal, defaultSku.discountedPrice);
  const totalAmount = (parseFloat(unitPrice) * input.quantity).toFixed(2);
  const { commission: commissionAmount, vendor: vendorAmount } = splitCommission(
    totalAmount,
    deal.commissionRate,
  );

  // 5. Validate payment method via Stripe and get providerCardToken (PM id).
  const checkResult = await (
    await deps.payments()
  ).checkCard({
    paymentMethodId: input.paymentMethodId,
  });

  if (!checkResult.ok) {
    await enqueueEvent(db, input.groupDealId, 'admin_alert.j2_check_failed', {
      groupDealId: input.groupDealId,
      userId: input.userId,
      code: checkResult.code,
      error: checkResult.message,
    });
    throw new GroupReservationError('HOLD_FAILED', `כרטיס האשראי נדחה: ${checkResult.message}`);
  }

  // 5b. Fetch vendor for hold (needs stripeAccountId as providerAccountId).
  const vendor = await vendorQueries.findById(db, deal.vendorId);
  if (!vendor) {
    throw new GroupReservationError('VENDOR_NOT_FOUND', 'Vendor not found for deal');
  }
  const vendorAccount: VendorAccount = {
    vendorId: deal.vendorId,
    providerAccountId: vendor.stripeAccountId ?? '',
    dealTitle: deal.title,
  };

  // 6. Insert group_reservations row (providerAuthorizationId set after placeHold).
  const reservation = await groupReservationQueries.create(db, {
    groupDealId: input.groupDealId,
    dealId: deal.id,
    userId: input.userId,
    paymentMethodId: input.paymentMethodId,
    quantity: input.quantity,
    unitPrice,
    totalAmount,
    commissionAmount,
    vendorAmount,
    idempotencyKey: input.idempotencyKey,
  });

  // 6b. Persist mock scenario BEFORE placeHold.
  if (deps.mockScenario != null) {
    await setReservationMockScenario(env.DATABASE_URL, reservation.id, deps.mockScenario);
  }

  // 6c. Place Stripe manual-capture hold (PaymentIntent with capture_method=manual).
  const holdResult = await (
    await deps.payments()
  ).placeHold({
    reservationId: reservation.id,
    providerCardToken: checkResult.providerCardToken,
    totalAgorot: Math.round(parseFloat(totalAmount) * 100),
    vendor: vendorAccount,
  });

  if (!holdResult.ok) {
    await groupReservationQueries.updateStatus(db, reservation.id, 'CANCELLED');
    await enqueueEvent(db, input.groupDealId, 'admin_alert.j5_hold_failed', {
      groupDealId: input.groupDealId,
      userId: input.userId,
      code: holdResult.code,
      error: holdResult.message,
    });
    throw new GroupReservationError('HOLD_FAILED', `החיוב נדחה: ${holdResult.message}`);
  }

  // 6d. Record hold reference on reservation row.
  await groupReservationQueries.updateStatus(db, reservation.id, 'HELD', {
    providerAuthorizationId: holdResult.providerHoldId,
    holdExpiresAt: new Date(holdResult.expiresAt),
  });

  // 7. Increment reservation count atomically
  const updatedGroupDeal = await groupDealQueries.incrementReservationCount(
    db,
    input.groupDealId,
    input.quantity,
  );

  if (!updatedGroupDeal) {
    throw new GroupReservationError('DB_ERROR', 'Failed to update group deal counters');
  }

  // 8. Post-reservation fan-out: decide threshold-met / execute-requested.
  //    Registered path triggers the vendor "threshold met" push notification.
  const fanOutResult = decide(
    { state: 'AWAITING_FANOUT' },
    {
      kind: 'reservation_placed',
      groupDealId: input.groupDealId,
      dealId: deal.id,
      vendorId: deal.vendorId,
      dealTitle: deal.title,
      newCount: updatedGroupDeal.currentReservationCount,
      minGroupSize: updatedGroupDeal.minGroupSize,
      maxGroupSize: updatedGroupDeal.maxGroupSize,
      previousGroupState: updatedGroupDeal.groupState as GroupState,
      isRegisteredPath: true,
      at: new Date(),
    },
  );

  if (fanOutResult.ok) {
    await applyEffects(
      {
        db,
        push: deps.push,
        doClient: deps.doClient,
        recordThresholdMet,
        recordThresholdLost,
      },
      fanOutResult.effects,
    );
  }

  return { reservation, updatedGroupDeal };
}

// ─── createGroupReservationGuest ──────────────────────────────────────────────

export interface CreateGroupReservationGuestInput {
  groupDealId: string;
  guestEmail: string;
  guestPhone: string;
  /** Stripe PaymentMethod ID from client-side Stripe.js (pm_xxx). */
  paymentMethodId: string;
  quantity: number;
  idempotencyKey: string;
}

export async function createGroupReservationGuest(
  deps: GroupReservationDeps,
  input: CreateGroupReservationGuestInput,
) {
  const { db } = deps;

  // 1. Load and validate group deal
  const groupDeal = await groupDealQueries.findById(db, input.groupDealId);
  if (!groupDeal) {
    throw new GroupReservationError('GROUP_DEAL_NOT_FOUND', 'Group deal not found');
  }

  const deal = await dealQueries.findById(db, groupDeal.dealId);
  if (!deal) {
    throw new GroupReservationError('DEAL_NOT_FOUND', 'Parent deal not found');
  }

  if (deal.dealState !== 'ACTIVE') {
    throw new GroupReservationError('DEAL_NOT_ACTIVE', `Deal is not active: ${deal.dealState}`);
  }

  if (groupDeal.groupState !== 'COLLECTING' && groupDeal.groupState !== 'THRESHOLD_MET') {
    throw new GroupReservationError(
      'GROUP_NOT_ACCEPTING',
      `Group deal is not accepting reservations: ${groupDeal.groupState}`,
    );
  }

  if (groupDeal.currentReservationCount >= groupDeal.maxGroupSize) {
    throw new GroupReservationError('GROUP_FULL', 'Group deal is full');
  }

  // 2. Encrypt PII for guest fields (needed for per-customer limit lookup)
  const encryptedEmail = deps.auth?.PII_KEY
    ? encrypt(input.guestEmail, deps.auth.PII_KEY)
    : input.guestEmail;
  const encryptedPhone = deps.auth?.PII_KEY
    ? encrypt(input.guestPhone, deps.auth.PII_KEY)
    : input.guestPhone;

  const existingGuestCount = await groupReservationQueries.countByGuestEmailAndGroupDeal(
    db,
    encryptedEmail as string,
    input.groupDealId,
  );

  const totalAfterGuestReservation = existingGuestCount + input.quantity;
  if (totalAfterGuestReservation > groupDeal.perCustomerLimit) {
    throw new GroupReservationError(
      'PER_CUSTOMER_LIMIT_EXCEEDED',
      `You can reserve at most ${groupDeal.perCustomerLimit} unit(s) for this group deal`,
    );
  }

  // 3. Determine unit price
  const guestDefaultSku = await getDefaultSku(db, deal.id);
  const unitPrice = await determineUnitPrice(db, groupDeal, guestDefaultSku.discountedPrice);
  const totalAmount = (parseFloat(unitPrice) * input.quantity).toFixed(2);
  const { commission: commissionAmount, vendor: vendorAmount } = splitCommission(
    totalAmount,
    deal.commissionRate,
  );

  // 4. Validate payment method via Stripe.
  const checkGuestResult = await (
    await deps.payments()
  ).checkCard({
    paymentMethodId: input.paymentMethodId,
  });

  if (!checkGuestResult.ok) {
    await enqueueEvent(db, input.groupDealId, 'admin_alert.j2_check_failed', {
      groupDealId: input.groupDealId,
      guestFlow: true,
      code: checkGuestResult.code,
      error: checkGuestResult.message,
    });
    throw new GroupReservationError(
      'HOLD_FAILED',
      `כרטיס האשראי נדחה: ${checkGuestResult.message}`,
    );
  }

  // 4b. Fetch vendor for hold.
  const vendorGuest = await vendorQueries.findById(db, deal.vendorId);
  if (!vendorGuest) {
    throw new GroupReservationError('VENDOR_NOT_FOUND', 'Vendor not found for deal');
  }
  const vendorAccountGuest: VendorAccount = {
    vendorId: deal.vendorId,
    providerAccountId: vendorGuest.stripeAccountId ?? '',
    dealTitle: deal.title,
  };

  // 5. Insert group_reservations row (providerAuthorizationId set after placeHold).
  const reservation = await groupReservationQueries.create(db, {
    groupDealId: input.groupDealId,
    dealId: deal.id,
    guestEmail: encryptedEmail as string,
    guestPhone: encryptedPhone as string,
    quantity: input.quantity,
    unitPrice,
    totalAmount,
    commissionAmount,
    vendorAmount,
    idempotencyKey: input.idempotencyKey,
  });

  // 5b. Persist mock scenario BEFORE placeHold.
  if (deps.mockScenario != null) {
    await setReservationMockScenario(env.DATABASE_URL, reservation.id, deps.mockScenario);
  }

  // 5c. Place Stripe manual-capture hold.
  const holdGuestResult = await (
    await deps.payments()
  ).placeHold({
    reservationId: reservation.id,
    providerCardToken: checkGuestResult.providerCardToken,
    totalAgorot: Math.round(parseFloat(totalAmount) * 100),
    vendor: vendorAccountGuest,
  });

  if (!holdGuestResult.ok) {
    await groupReservationQueries.updateStatus(db, reservation.id, 'CANCELLED');
    await enqueueEvent(db, input.groupDealId, 'admin_alert.j5_hold_failed', {
      groupDealId: input.groupDealId,
      guestFlow: true,
      code: holdGuestResult.code,
      error: holdGuestResult.message,
    });
    throw new GroupReservationError('HOLD_FAILED', `החיוב נדחה: ${holdGuestResult.message}`);
  }

  // 5d. Record hold reference on reservation row.
  await groupReservationQueries.updateStatus(db, reservation.id, 'HELD', {
    providerAuthorizationId: holdGuestResult.providerHoldId,
    holdExpiresAt: new Date(holdGuestResult.expiresAt),
  });

  // 6. Increment reservation count atomically
  const updatedGroupDeal = await groupDealQueries.incrementReservationCount(
    db,
    input.groupDealId,
    input.quantity,
  );

  if (!updatedGroupDeal) {
    throw new GroupReservationError('DB_ERROR', 'Failed to update group deal counters');
  }

  // 7. Post-reservation fan-out: decide threshold-met / execute-requested.
  //    Guest path SUPPRESSES the vendor "threshold met" push notification
  //    (pre-split behaviour preserved).
  const fanOutResultGuest = decide(
    { state: 'AWAITING_FANOUT' },
    {
      kind: 'reservation_placed',
      groupDealId: input.groupDealId,
      dealId: deal.id,
      vendorId: deal.vendorId,
      dealTitle: deal.title,
      newCount: updatedGroupDeal.currentReservationCount,
      minGroupSize: updatedGroupDeal.minGroupSize,
      maxGroupSize: updatedGroupDeal.maxGroupSize,
      previousGroupState: updatedGroupDeal.groupState as GroupState,
      isRegisteredPath: false,
      at: new Date(),
    },
  );

  if (fanOutResultGuest.ok) {
    await applyEffects(
      {
        db,
        push: deps.push,
        doClient: deps.doClient,
        recordThresholdMet,
        recordThresholdLost,
      },
      fanOutResultGuest.effects,
    );
  }

  return { reservation, updatedGroupDeal };
}

// ─── cancelGroupReservation ───────────────────────────────────────────────────

/**
 * Cancels a reservation and releases the hold.
 * Enforces Israeli consumer law (14-day free cancellation) and vendor cancellation policy.
 */
export async function cancelGroupReservation(
  deps: GroupReservationDeps,
  reservationId: string,
  userId: string,
) {
  const { db } = deps;

  // 1. Load reservation and verify ownership
  const reservation = await groupReservationQueries.findById(db, reservationId);
  if (!reservation) {
    throw new GroupReservationError('RESERVATION_NOT_FOUND', 'Reservation not found');
  }

  if (reservation.userId !== userId) {
    throw new GroupReservationError('UNAUTHORIZED', 'Reservation does not belong to this user');
  }

  if (reservation.status !== 'HELD') {
    throw new GroupReservationError(
      'INVALID_STATUS',
      `Cannot cancel reservation in status ${reservation.status}`,
    );
  }

  // 2. Load group deal for cancellation policy
  const groupDeal = await groupDealQueries.findById(db, reservation.groupDealId);
  if (!groupDeal) {
    throw new GroupReservationError('GROUP_DEAL_NOT_FOUND', 'Group deal not found');
  }

  const deal = await dealQueries.findById(db, groupDeal.dealId);
  if (!deal) {
    throw new GroupReservationError('DEAL_NOT_FOUND', 'Parent deal not found');
  }

  // 3. Compute days since reservation (pure value passed to decider)
  const now = new Date();
  const daysSinceReservation =
    (now.getTime() - reservation.createdAt.getTime()) / (1000 * 60 * 60 * 24);

  // 4. Cancel transition: decide → applyEffects.
  //    mark-cancelled, release-hold, decrement-count, enqueue-outbox(cancelled).
  const cancelCtx: GroupReservationContext = { state: 'HELD' };
  const cancelResult = decide(cancelCtx, {
    kind: 'cancel_requested',
    reservationId,
    groupDealId: reservation.groupDealId,
    dealId: deal.id,
    dealTitle: deal.title,
    userId,
    quantity: reservation.quantity,
    cancellationPolicy: groupDeal.cancellationPolicy as CancellationPolicy,
    daysSinceReservation,
    hasHold: reservation.providerAuthorizationId !== null,
    providerAuthorizationId: reservation.providerAuthorizationId ?? null,
    at: now,
  });

  if (!cancelResult.ok) {
    if (cancelResult.error === 'CANCELLATION_NOT_ALLOWED') {
      throw new GroupReservationError(
        'CANCELLATION_NOT_ALLOWED',
        'Cancellation is not allowed past 14 days with LEGAL_ONLY policy',
      );
    }
    throw new GroupReservationError(
      'INVALID_STATUS',
      `Cannot cancel reservation in state ${cancelResult.from}`,
    );
  }

  await applyEffects(
    {
      db,
      push: deps.push,
      doClient: deps.doClient,
      recordThresholdMet,
      recordThresholdLost,
    },
    cancelResult.effects,
  );

  // 5. Post-cancellation fan-out: decide on threshold-lost using the
  //    updated count, then apply.
  const updatedGroupDeal = await groupDealQueries.findById(db, reservation.groupDealId);

  if (updatedGroupDeal) {
    const finalizeResult = decide(
      { state: 'AWAITING_FANOUT' },
      {
        kind: 'cancellation_finalized',
        reservationId,
        groupDealId: reservation.groupDealId,
        dealId: deal.id,
        dealTitle: deal.title,
        vendorId: deal.vendorId,
        userId,
        newCount: updatedGroupDeal.currentReservationCount,
        minGroupSize: updatedGroupDeal.minGroupSize,
        currentGroupState: updatedGroupDeal.groupState as GroupState,
        at: now,
      },
    );

    if (finalizeResult.ok) {
      await applyEffects(
        {
          db,
          push: deps.push,
          doClient: deps.doClient,
          recordThresholdMet,
          recordThresholdLost,
        },
        finalizeResult.effects,
      );
    }
  }

  // 6. Promote from waitlist if any entry exists
  await promoteFromWaitlist(deps, reservation.groupDealId);

  return { reservationId, cancelled: true };
}

// ─── joinGroupWaitlist ────────────────────────────────────────────────────────

export interface JoinGroupWaitlistInput {
  groupDealId: string;
  userId?: string;
  guestEmail?: string;
}

export async function joinGroupWaitlist(deps: GroupReservationDeps, input: JoinGroupWaitlistInput) {
  const { db } = deps;

  const groupDeal = await groupDealQueries.findById(db, input.groupDealId);
  if (!groupDeal) {
    throw new GroupReservationError('GROUP_DEAL_NOT_FOUND', 'Group deal not found');
  }

  // Verify the deal is actually full
  if (groupDeal.currentReservationCount < groupDeal.maxGroupSize) {
    throw new GroupReservationError(
      'GROUP_NOT_FULL',
      'Group deal still has available slots - join directly instead',
    );
  }

  if (
    groupDeal.groupState !== 'COLLECTING' &&
    groupDeal.groupState !== 'THRESHOLD_MET' &&
    groupDeal.groupState !== 'EXTENDED'
  ) {
    throw new GroupReservationError(
      'GROUP_NOT_ACCEPTING_WAITLIST',
      `Group deal is not accepting waitlist entries: ${groupDeal.groupState}`,
    );
  }

  const entry = await groupWaitlistQueries.add(db, {
    groupDealId: input.groupDealId,
    userId: input.userId,
    guestEmail: input.guestEmail,
  });

  return { waitlistId: entry.id, position: entry.position };
}

// ─── leaveGroupWaitlist ───────────────────────────────────────────────────────

export async function leaveGroupWaitlist(
  deps: GroupReservationDeps,
  waitlistId: string,
  userId: string,
) {
  const { db } = deps;

  // Fetch entry and verify ownership directly via schema
  const [waitlistEntry] = await db
    .select()
    .from(groupWaitlist)
    .where(eq(groupWaitlist.id, waitlistId))
    .limit(1);

  if (!waitlistEntry) {
    throw new GroupReservationError('WAITLIST_ENTRY_NOT_FOUND', 'Waitlist entry not found');
  }

  if (waitlistEntry.userId !== userId) {
    throw new GroupReservationError('UNAUTHORIZED', 'Waitlist entry does not belong to this user');
  }

  await groupWaitlistQueries.removeById(db, waitlistId);

  return { waitlistId, removed: true };
}

// ─── promoteFromWaitlist ──────────────────────────────────────────────────────

/**
 * Promotes the next waitlisted user when a reservation slot opens up.
 * Sends a push notification to the promoted user.
 *
 * Note: promoteNext (DB pop) happens BEFORE decide so the decider sees the
 * concrete promoted entry; the decider then emits no-op effects when no
 * candidate exists, and outbox/push effects otherwise. The promote-waitlist
 * effect is therefore a marker: the actual DB write occurred up-front.
 */
export async function promoteFromWaitlist(deps: GroupReservationDeps, groupDealId: string) {
  const { db } = deps;

  const groupDeal = await groupDealQueries.findById(db, groupDealId);
  if (!groupDeal) return null;

  const deal = await dealQueries.findById(db, groupDeal.dealId);
  if (!deal) return null;

  // Pop the next waitlist entry directly so we can inform the decider whether
  // a candidate exists and who it is.
  const nextEntry = await groupWaitlistQueries.promoteNext(db, groupDealId);

  const promoteResult = decide(
    { state: 'AWAITING_PROMOTION' },
    {
      kind: 'promote_requested',
      groupDealId,
      dealId: deal.id,
      dealTitle: deal.title,
      promotedWaitlistId: nextEntry?.id ?? null,
      promotedUserId: nextEntry?.userId ?? null,
      at: new Date(),
    },
  );

  if (promoteResult.ok) {
    // Filter out the promote-waitlist effect — already executed above to
    // obtain `nextEntry`. The marker presence preserves the canonical effect
    // sequence in the equivalence fixtures.
    const effects = promoteResult.effects.filter((e) => e.kind !== 'promote-waitlist');
    await applyEffects(
      {
        db,
        push: deps.push,
        doClient: deps.doClient,
        recordThresholdMet,
        recordThresholdLost,
      },
      effects,
    );
  }

  return nextEntry ?? null;
}
