import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import {
  CouponStatus,
  CouponValueType,
  CustomerCouponStatus,
  LoyaltyEarnType,
  ModerationStatus,
  SaleStatus,
  UserRole,
} from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { RegisterSaleDto } from './dto/register-sale.dto';
import { MailService } from '../mail/mail.service';

type RequestUser = { id: string; role: UserRole; shopId: string | null };

@Injectable()
export class SalesService {
  private readonly logger = new Logger(SalesService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly mail: MailService,
  ) {}

  private queueCouponUnlockedEmail(args: {
    to?: string | null;
    recipientName?: string | null;
    couponTitle: string;
    shopName: string;
  }) {
    if (!args.to) return;
    void this.mail
      .sendCouponUnlocked({
        to: args.to,
        recipientName: args.recipientName,
        couponTitle: args.couponTitle,
        shopName: args.shopName,
        appUrl: process.env.CASHI_APP_URL ?? process.env.APP_PUBLIC_URL ?? null,
      })
      .catch((error: unknown) => {
        const message =
          error instanceof Error ? error.message : 'unknown mailer error';
        this.logger.warn(`Failed to send coupon unlock email: ${message}`);
      });
  }

  private defaultLoyaltySettings(shopId: string) {
    return {
      shopId,
      minOrderFirstPoint: 200,
      minOrderApplyPoint: 200,
      validityMonths: 6,
      maxEarnablePoints: 100,
      maxRedeemPercent: 15,
      earnType: LoyaltyEarnType.INCREMENTAL,
      percentageRate: null,
      stepAmount: 50,
      pointsPerStep: 10,
      welcomeBonus: 10,
      referralBonus: 10,
    };
  }

  private calculatePointsEarned(
    settings: {
      minOrderFirstPoint: number;
      maxEarnablePoints: number;
      earnType: LoyaltyEarnType;
      percentageRate: number | null;
      stepAmount: number | null;
      pointsPerStep: number | null;
    },
    earnBaseAmount: number,
  ) {
    let pointsEarned = 0;
    if (earnBaseAmount >= Number(settings.minOrderFirstPoint ?? 0)) {
      if (settings.earnType === LoyaltyEarnType.INCREMENTAL) {
        const stepAmount = Number(settings.stepAmount ?? 0);
        const pointsPerStep = Number(settings.pointsPerStep ?? 0);
        const eligible = Math.max(
          0,
          earnBaseAmount - Number(settings.minOrderFirstPoint ?? 0),
        );
        const steps = stepAmount > 0 ? Math.floor(eligible / stepAmount) : 0;
        pointsEarned = steps * pointsPerStep;
      } else if (settings.earnType === LoyaltyEarnType.PERCENTAGE) {
        const rate = Number(settings.percentageRate ?? 0);
        pointsEarned =
          rate >= 100
            ? earnBaseAmount
            : Math.floor((earnBaseAmount * rate) / 100);
      }
    }
    const cap = Number(settings.maxEarnablePoints ?? 0);
    const rate = Number(settings.percentageRate ?? 0);
    const shouldApplyCap = !(
      settings.earnType === LoyaltyEarnType.PERCENTAGE && rate >= 100
    );
    if (cap >= 0 && shouldApplyCap) pointsEarned = Math.min(pointsEarned, cap);
    return Math.max(0, Math.floor(pointsEarned));
  }

  private normalizeSaleForResponse<T extends Record<string, any>>(
    sale: T,
    firstSettingsCreatedAt?: Date | null,
  ) {
    const normalized = this.mapSaleForResponse(sale);
    if (!firstSettingsCreatedAt) return normalized;
    const createdAt = new Date(String(sale.createdAt ?? ''));
    if (!Number.isFinite(createdAt.getTime())) return normalized;
    if (createdAt >= firstSettingsCreatedAt) return normalized;

    const expectedPoints = this.calculatePointsEarned(
      this.defaultLoyaltySettings(String(sale.shopId)),
      Number(sale.amount ?? 0),
    );
    return {
      ...normalized,
      cashiPointsEarned: expectedPoints,
      loyaltyPointsEarned: expectedPoints,
    };
  }

  private mapSaleForResponse<T extends Record<string, any>>(s: T) {
    const cashiCoinsEarned = Number(s.cashiCoinsEarned ?? 0);
    const cashiPointsEarned = Number(s.loyaltyPointsEarned ?? 0);
    const cashiPointsRedeemed = Number(s.loyaltyPointsRedeemed ?? 0);
    const {
      cashiCoinsEarned: _cashiCoinsEarned,
      loyaltyPointsEarned: _loyaltyPointsEarned,
      loyaltyPointsRedeemed: _loyaltyPointsRedeemed,
      ...rest
    } = s as any;
    return {
      ...rest,
      cashiCoinsEarned,
      cashiPointsEarned,
      cashiPointsRedeemed,
    };
  }

  private async assertCanAccessShop(user: RequestUser, shopId: string) {
    if (user.role === UserRole.SUPERADMIN) return;
    if (user.role === UserRole.ADMIN) {
      const shop = await this.prisma.shop.findUnique({
        where: { id: shopId },
        select: { adminId: true },
      });
      if (!shop) throw new NotFoundException('Shop not found');
      if (shop.adminId !== user.id) throw new ForbiddenException('Forbidden');
      return;
    }
    if (!user.shopId || user.shopId !== shopId)
      throw new ForbiddenException('Forbidden');
  }

  async list(user: RequestUser, shopIdQuery?: string) {
    const shopId =
      user.role === UserRole.SUPERADMIN
        ? (shopIdQuery ?? null)
        : (user.shopId ?? null);
    if (!shopId) throw new ForbiddenException('No shop selected');
    await this.assertCanAccessShop(user, shopId);
    const settingsMeta = await this.prisma.loyaltySettings.findUnique({
      where: { shopId },
      select: { createdAt: true },
    });

    const items = await this.prisma.sale.findMany({
      where: { shopId },
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        shopId: true,
        amount: true,
        originalAmount: true,
        discountAmount: true,
        appliedCouponId: true,
        loyaltyPointsRedeemed: true,
        loyaltyPointsEarned: true,
        cashiCoinsEarned: true,
        notes: true,
        status: true,
        createdAt: true,
        appliedCoupon: { select: { id: true, title: true } },
        customer: {
          select: { id: true, name: true, phone: true, email: true },
        },
      },
    });
    return items.map((s) =>
      this.normalizeSaleForResponse(s, settingsMeta?.createdAt ?? null),
    );
  }

  async listPaged(
    user: RequestUser,
    args: {
      page: number;
      limit: number;
      q?: string;
      customerId?: string;
      status?: string;
      from?: string;
      to?: string;
      minAmount?: string;
      maxAmount?: string;
      shopId?: string;
    },
  ) {
    const page = Math.max(1, Math.floor(args.page || 1));
    const limit = Math.min(100, Math.max(1, Math.floor(args.limit || 20)));
    const skip = (page - 1) * limit;

    const shopId =
      user.role === UserRole.SUPERADMIN
        ? args.shopId?.trim() || user.shopId || null
        : (user.shopId ?? null);
    if (!shopId) throw new ForbiddenException('No shop selected');
    await this.assertCanAccessShop(user, shopId);
    const settingsMeta = await this.prisma.loyaltySettings.findUnique({
      where: { shopId },
      select: { createdAt: true },
    });

    const q = String(args.q ?? '').trim();
    const statusRaw =
      args.status != null ? String(args.status).trim().toUpperCase() : '';

    let status: SaleStatus | undefined;
    if (statusRaw === 'COMPLETED') status = SaleStatus.COMPLETED;
    else if (statusRaw === 'PENDING') status = SaleStatus.PENDING;
    else if (statusRaw === 'CANCELLED') status = SaleStatus.CANCELLED;

    const minAmountNum =
      args.minAmount != null && String(args.minAmount).trim() !== ''
        ? Number(args.minAmount)
        : null;
    const maxAmountNum =
      args.maxAmount != null && String(args.maxAmount).trim() !== ''
        ? Number(args.maxAmount)
        : null;

    const parseDate = (
      s?: string,
      boundary: 'start' | 'end' = 'start',
    ) => {
      const raw = (s ?? '').trim();
      if (!raw) return null;
      const dateOnlyMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
      if (dateOnlyMatch) {
        const [, yearRaw, monthRaw, dayRaw] = dateOnlyMatch;
        const year = Number(yearRaw);
        const month = Number(monthRaw);
        const day = Number(dayRaw);
        const d = new Date(
          Date.UTC(
            year,
            month - 1,
            day,
            boundary === 'end' ? 23 : 0,
            boundary === 'end' ? 59 : 0,
            boundary === 'end' ? 59 : 0,
            boundary === 'end' ? 999 : 0,
          ),
        );
        if (Number.isNaN(d.getTime()))
          throw new BadRequestException('Invalid date');
        return d;
      }
      const d = new Date(raw);
      if (Number.isNaN(d.getTime()))
        throw new BadRequestException('Invalid date');
      return d;
    };
    const from = parseDate(args.from, 'start');
    const to = parseDate(args.to, 'end');

    const where: any = { shopId };
    const customerId = String(args.customerId ?? '').trim();
    if (customerId) where.customerId = customerId;
    if (status) where.status = status;
    if (minAmountNum != null && Number.isFinite(minAmountNum))
      where.amount = { ...(where.amount ?? {}), gte: Math.floor(minAmountNum) };
    if (maxAmountNum != null && Number.isFinite(maxAmountNum))
      where.amount = { ...(where.amount ?? {}), lte: Math.floor(maxAmountNum) };
    if (from || to) {
      where.createdAt = {
        ...(from ? { gte: from } : {}),
        ...(to ? { lte: to } : {}),
      };
    }
    if (q) {
      where.OR = [
        { id: { contains: q, mode: 'insensitive' } },
        { customer: { name: { contains: q, mode: 'insensitive' } } },
        { customer: { phone: { contains: q, mode: 'insensitive' } } },
        { customer: { email: { contains: q, mode: 'insensitive' } } },
        { notes: { contains: q, mode: 'insensitive' } },
      ];
    }

    const [total, items] = await this.prisma.$transaction([
      this.prisma.sale.count({ where }),
      this.prisma.sale.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip,
        take: limit,
        select: {
          id: true,
          shopId: true,
          amount: true,
          originalAmount: true,
          discountAmount: true,
          appliedCouponId: true,
          loyaltyPointsRedeemed: true,
          loyaltyPointsEarned: true,
          cashiCoinsEarned: true,
          notes: true,
          status: true,
          createdAt: true,
          appliedCoupon: { select: { id: true, title: true } },
          customer: {
            select: { id: true, name: true, phone: true, email: true },
          },
        },
      }),
    ]);

    return {
      page,
      limit,
      total,
      items: items.map((s) =>
        this.normalizeSaleForResponse(s, settingsMeta?.createdAt ?? null),
      ),
    };
  }

  async register(user: RequestUser, dto: RegisterSaleDto) {
    const shopId = user.shopId ?? null;
    if (!shopId) throw new ForbiddenException('No shop selected');
    await this.assertCanAccessShop(user, shopId);

    const phone = dto.phone.trim();
    const originalAmount = Math.floor(dto.amount);
    if (!Number.isFinite(originalAmount) || originalAmount <= 0)
      throw new BadRequestException('Invalid amount');

    const existing = await this.prisma.user.findUnique({
      where: { phone },
      select: {
        id: true,
        role: true,
        shopId: true,
        name: true,
        email: true,
        phone: true,
        passwordHash: true,
      },
    });

    let customerId: string;
    if (existing) {
      // Allow staff accounts to be treated as "customers" for sale registration too.
      // Phone is globally unique, so we cannot create a separate customer user with same phone.
      customerId = existing.id;
    } else {
      if (!dto.name || !dto.name.trim())
        throw new BadRequestException(
          'Customer name is required for new customer',
        );
      try {
        const created = await this.prisma.user.create({
          data: {
            role: UserRole.USER,
            phone,
            email: dto.email?.trim() ? dto.email.trim().toLowerCase() : null,
            name: dto.name.trim(),
          },
          select: { id: true },
        });
        customerId = created.id;
      } catch {
        // phone/email unique conflicts
        throw new ConflictException('Customer already exists');
      }
    }

    // Ensure customer is linked to this shop (multi-shop customers).
    await this.prisma.customerShop.upsert({
      where: { userId_shopId: { userId: customerId, shopId } },
      create: { userId: customerId, shopId },
      update: { lastSeenAt: new Date() },
    });

    // Get available assigned coupons for this shop (before redeeming).
    const availableAssigned = await this.prisma.customerCoupon.findMany({
      where: {
        customerId,
        status: CustomerCouponStatus.ASSIGNED,
        coupon: {
          shopId,
          status: CouponStatus.ACTIVE,
          moderationStatus: ModerationStatus.PUBLISHED,
          OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
        },
      },
      orderBy: { assignedAt: 'desc' },
      select: {
        id: true,
        coupon: {
          select: {
            id: true,
            shopId: true,
            title: true,
            valueType: true,
            valueFixed: true,
            valuePercent: true,
            minOrderValue: true,
            expiresAt: true,
            imageUrl: true,
          },
        },
        assignedAt: true,
      },
    });

    const applyCouponId = dto.applyCouponId?.trim() || null;
    let couponDiscount = 0;
    let appliedCouponId: string | null = null;
    let redeemAssignmentId: string | null = null;

    if (applyCouponId) {
      const assignment = await this.prisma.customerCoupon.findFirst({
        where: {
          customerId,
          status: CustomerCouponStatus.ASSIGNED,
          couponId: applyCouponId,
          coupon: {
            shopId,
            status: CouponStatus.ACTIVE,
            moderationStatus: ModerationStatus.PUBLISHED,
          },
        },
        select: {
          id: true,
          coupon: {
            select: {
              id: true,
              valueType: true,
              valueFixed: true,
              valuePercent: true,
              minOrderValue: true,
            },
          },
        },
      });
      if (!assignment)
        throw new BadRequestException(
          'Coupon is not available for this customer',
        );

      const min = assignment.coupon.minOrderValue ?? null;
      if (min != null && originalAmount < min) {
        throw new BadRequestException(
          `Minimum order is ₹${min} for this coupon`,
        );
      }

      if (assignment.coupon.valueType === CouponValueType.FIXED) {
        couponDiscount = Math.max(
          0,
          Math.floor(assignment.coupon.valueFixed ?? 0),
        );
      } else {
        const pct = Number(assignment.coupon.valuePercent ?? 0);
        couponDiscount = Math.floor((originalAmount * pct) / 100);
      }

      if (couponDiscount > originalAmount) couponDiscount = originalAmount;
      appliedCouponId = assignment.coupon.id;
      redeemAssignmentId = assignment.id;
    }

    // Loyalty points (store-wise) redemption + earning
    const settings =
      (await this.prisma.loyaltySettings.findUnique({ where: { shopId } })) ??
      this.defaultLoyaltySettings(shopId);

    // Available (non-expired) points for this shop/customer
    const now = new Date();
    const grants = await this.prisma.loyaltyPointGrant.findMany({
      where: {
        shopId,
        customerId,
        expiresAt: { gt: now },
        pointsRemaining: { gt: 0 },
      },
      orderBy: [{ expiresAt: 'asc' }, { createdAt: 'asc' }],
      select: { id: true, pointsRemaining: true, expiresAt: true },
    });
    const availablePoints = grants.reduce(
      (sum, g) => sum + g.pointsRemaining,
      0,
    );

    let pointsRedeemed = 0;
    if (availablePoints > 0 && originalAmount >= settings.minOrderApplyPoint) {
      const maxByPercent = Math.floor(
        (originalAmount * settings.maxRedeemPercent) / 100,
      );
      pointsRedeemed = Math.max(0, Math.min(availablePoints, maxByPercent));
    }

    // 1 point = ₹1 discount
    const loyaltyDiscount = pointsRedeemed;
    let discountAmount = couponDiscount + loyaltyDiscount;
    if (discountAmount > originalAmount) discountAmount = originalAmount;
    const finalAmount = Math.max(0, originalAmount - discountAmount);
    // Cashi Coins are rewarded on what the customer actually pays (final amount).
    // (User requirement) If customer pays ₹X, they earn X Cashi coins.
    const cashiCoinsEarned = Math.max(0, Math.floor(finalAmount));

    const sale = await this.prisma.$transaction(async (tx) => {
      const created = await tx.sale.create({
        data: {
          shopId,
          customerId,
          amount: finalAmount,
          originalAmount,
          discountAmount,
          appliedCouponId: appliedCouponId ?? null,
          loyaltyPointsRedeemed: pointsRedeemed,
          // Cashi Coins are earned based on final payable amount for this sale.
          cashiCoinsEarned: cashiCoinsEarned,
          customerLat: dto.customerLat ?? null,
          customerLng: dto.customerLng ?? null,
          notes: dto.notes?.trim() ? dto.notes.trim() : null,
          status: SaleStatus.COMPLETED,
        },
        select: {
          id: true,
          shopId: true,
          amount: true,
          originalAmount: true,
          discountAmount: true,
          appliedCouponId: true,
          loyaltyPointsRedeemed: true,
          loyaltyPointsEarned: true,
          cashiCoinsEarned: true,
          notes: true,
          status: true,
          createdAt: true,
          customer: {
            select: {
              id: true,
              name: true,
              phone: true,
              email: true,
              passwordHash: true,
            },
          },
        },
      });

      if (redeemAssignmentId && appliedCouponId) {
        await tx.customerCoupon.update({
          where: { id: redeemAssignmentId },
          data: {
            status: CustomerCouponStatus.REDEEMED,
            redeemedAt: new Date(),
            redeemedSaleId: created.id,
          },
          select: { id: true },
        });
        await tx.coupon.update({
          where: { id: appliedCouponId },
          data: { redeemedCount: { increment: 1 } },
          select: { id: true },
        });
      }

      if (pointsRedeemed > 0) {
        // consume from earliest expiring grants
        let remaining = pointsRedeemed;
        for (const g of grants) {
          if (remaining <= 0) break;
          const take = Math.min(remaining, g.pointsRemaining);
          if (take <= 0) continue;
          await tx.loyaltyPointGrant.update({
            where: { id: g.id },
            data: { pointsRemaining: { decrement: take } },
            select: { id: true },
          });
          await tx.loyaltyPointUsage.create({
            data: { grantId: g.id, saleId: created.id, pointsUsed: take },
            select: { id: true },
          });
          remaining -= take;
        }

        // Keep cached loyalty points balance in sync for fast fetch.
        await tx.user.update({
          where: { id: customerId },
          data: { cashiPoints: { decrement: pointsRedeemed } },
          select: { id: true },
        });
      }

      // Earn points for this sale (based on settings)
      // Use the actual amount the customer pays (after coupon + points redemption).
      // This matches the "earn on payable amount" expectation.
      const earnBaseAmount = finalAmount;
      const pointsEarned = this.calculatePointsEarned(settings, earnBaseAmount);
      if (pointsEarned > 0) {
        const expiresAt = new Date(now);
        expiresAt.setMonth(
          expiresAt.getMonth() + Number(settings.validityMonths ?? 0),
        );
        await tx.loyaltyPointGrant.create({
          data: {
            shopId,
            customerId,
            earnedSaleId: created.id,
            pointsGranted: pointsEarned,
            pointsRemaining: pointsEarned,
            expiresAt,
          },
          select: { id: true },
        });
        await tx.sale.update({
          where: { id: created.id },
          data: { loyaltyPointsEarned: pointsEarned },
          select: { id: true },
        });
        (created as any).loyaltyPointsEarned = pointsEarned;

        // Keep cached loyalty points balance in sync for fast fetch.
        await tx.user.update({
          where: { id: customerId },
          data: { cashiPoints: { increment: pointsEarned } },
          select: { id: true },
        });
      }

      // Credit Cashi Coins (wallet) based on final payable amount.
      if (cashiCoinsEarned > 0) {
        await tx.user.update({
          where: { id: customerId },
          data: { cashiCoins: { increment: cashiCoinsEarned } },
          select: { id: true },
        });
      }

      return created;
    });

    // Distribute a coupon after sale:
    // - Prefer coupons from the nearest shop to the customer, otherwise fallback to current shop.
    const distributed: Array<{
      couponId: string;
      shopId: string;
      couponTitle: string;
      shopName: string;
    }> = [];
    try {
      const radiusEnv = Number(process.env.COUPON_UNLOCK_RADIUS_KM ?? '10');
      const baseRadiusKm =
        Number.isFinite(radiusEnv) && radiusEnv > 0 ? radiusEnv : 10;

      let lat = dto.customerLat;
      let lng = dto.customerLng;

      // If UI didn't send coordinates, use stored customer location (best effort).
      if (typeof lat !== 'number' || typeof lng !== 'number') {
        const u = await this.prisma.user.findUnique({
          where: { id: customerId },
          select: { latitude: true, longitude: true },
        });
        if (u?.latitude != null && u?.longitude != null) {
          lat = u.latitude;
          lng = u.longitude;
        }
      }

      const haversineKm = (
        aLat: number,
        aLng: number,
        bLat: number,
        bLng: number,
      ) => {
        const toRad = (x: number) => (x * Math.PI) / 180;
        const R = 6371;
        const dLat = toRad(bLat - aLat);
        const dLng = toRad(bLng - aLng);
        const s1 = Math.sin(dLat / 2) ** 2;
        const s2 =
          Math.cos(toRad(aLat)) *
          Math.cos(toRad(bLat)) *
          Math.sin(dLng / 2) ** 2;
        return 2 * R * Math.asin(Math.sqrt(s1 + s2));
      };

      let nearbyShopIds: string[] = [];
      if (typeof lat === 'number' && typeof lng === 'number') {
        const shops = await this.prisma.shop.findMany({
          where: {
            status: 'APPROVED',
            isActive: true,
            latitude: { not: null },
            longitude: { not: null },
          },
          select: { id: true, latitude: true, longitude: true },
        });
        const within = shops
          .map((s) => ({
            id: s.id,
            d: haversineKm(
              lat,
              lng,
              s.latitude as number,
              s.longitude as number,
            ),
          }))
          .filter((x) => x.d <= baseRadiusKm)
          .sort((a, b) => a.d - b.d);
        nearbyShopIds = within.map((x) => x.id);
      }
      // Prefer nearest shop; fallback to current shop.
      const nearestShopId = nearbyShopIds[0] ?? shopId;
      const candidateShopIds =
        nearestShopId === shopId ? [shopId] : [nearestShopId, shopId];

      const candidateCoupons = await this.prisma.coupon.findMany({
        where: {
          shopId: { in: candidateShopIds },
          status: CouponStatus.ACTIVE,
          moderationStatus: ModerationStatus.PUBLISHED,
          OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
        },
        select: {
          id: true,
          shopId: true,
          title: true,
          totalCoupons: true,
          issuedCount: true,
          distributionBid: true,
          createdAt: true,
          shop: {
            select: {
              name: true,
            },
          },
        },
        orderBy: [{ createdAt: 'desc' }],
      });

      // Pick first coupon that still has stock and isn't already assigned.
      const shopRank = new Map<string, number>();
      candidateShopIds.forEach((id, idx) => shopRank.set(id, idx));
      const orderedCoupons = [...candidateCoupons].sort((a, b) => {
        const ra = shopRank.get(a.shopId) ?? 999;
        const rb = shopRank.get(b.shopId) ?? 999;
        if (ra !== rb) return ra - rb;
        return (b.createdAt as any) - (a.createdAt as any);
      });

      for (const c of orderedCoupons) {
        if (c.totalCoupons != null && c.issuedCount >= c.totalCoupons) continue;

        // If coupon has specific radius (distributionBid), enforce it when we have coordinates.
        if (
          typeof lat === 'number' &&
          typeof lng === 'number' &&
          c.distributionBid != null &&
          c.shopId
        ) {
          const s = await this.prisma.shop.findUnique({
            where: { id: c.shopId },
            select: { latitude: true, longitude: true },
          });
          if (s?.latitude != null && s?.longitude != null) {
            const d = haversineKm(lat, lng, s.latitude, s.longitude);
            if (d > c.distributionBid) continue;
          }
        }

        const exists = await this.prisma.customerCoupon.findUnique({
          where: { customerId_couponId: { customerId, couponId: c.id } },
          select: { id: true },
        });
        if (exists) continue;

        await this.prisma.$transaction(async (tx) => {
          await tx.customerCoupon.create({
            data: {
              customerId,
              couponId: c.id,
              status: CustomerCouponStatus.ASSIGNED,
            },
            select: { id: true },
          });
          await tx.coupon.update({
            where: { id: c.id },
            data: { issuedCount: { increment: 1 } },
            select: { id: true },
          });
        });
        distributed.push({
          couponId: c.id,
          shopId: c.shopId,
          couponTitle: c.title,
          shopName: c.shop.name,
        });
        break;
      }
    } catch {
      // best effort distribution
    }

    if (distributed[0] && sale.customer?.email) {
      this.queueCouponUnlockedEmail({
        to: sale.customer.email,
        recipientName: sale.customer.name,
        couponTitle: distributed[0].couponTitle,
        shopName: distributed[0].shopName,
      });
    }

    const { customer, ...rest } = sale;
    return {
      ...rest,
      customer: customer
        ? (({ passwordHash, ...safe }) => ({
            ...safe,
            isCashiUser: !!passwordHash,
          }))(customer as any)
        : customer,
      availableCoupons: availableAssigned
        .map((a) => a.coupon)
        .filter((c) => c != null)
        .filter((c) =>
          c.minOrderValue == null ? true : originalAmount >= c.minOrderValue,
        ),
      cashiCoins: {
        earned: (sale as any).cashiCoinsEarned ?? 0,
      },
      cashiPoints: {
        available: availablePoints,
        redeemed: pointsRedeemed,
        earned: (sale as any).loyaltyPointsEarned ?? 0,
      },
      distributedCoupons: distributed,
    };
  }
}
