import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import {
  CustomerCouponStatus,
  CouponStatus,
  CouponValueType,
  ModerationStatus,
  Prisma,
  SaleStatus,
  UserRole,
} from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { CreateCouponDto } from './dto/create-coupon.dto';
import { UpdateCouponDto } from './dto/update-coupon.dto';
import { UploadsService } from '../uploads/uploads.service';
import type { RejectCouponDto } from './dto/reject-coupon.dto';
import { PlanLimitException } from '../plans/plan-limit.exception';
import { parseMonthlyLimit } from '../plans/plan-limits';

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

function parseDateOrNull(s?: string | null, opts?: { endOfDay?: boolean }) {
  if (s == null || s === '') return null;
  const raw = String(s).trim();
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
  if (m) {
    const y = Number(m[1]);
    const mo = Number(m[2]);
    const d = Number(m[3]);
    const end = !!opts?.endOfDay;
    return new Date(
      y,
      mo - 1,
      d,
      end ? 23 : 0,
      end ? 59 : 0,
      end ? 59 : 0,
      end ? 999 : 0,
    );
  }
  const d = new Date(raw);
  if (Number.isNaN(d.getTime())) throw new BadRequestException('Invalid date');
  return d;
}

function assertDateOrder(activeAt: Date | null, expiresAt: Date | null) {
  if (activeAt && expiresAt && activeAt.getTime() > expiresAt.getTime()) {
    throw new BadRequestException('activeAt must be before expiresAt');
  }
}

function escapeXml(value: unknown) {
  return String(value ?? '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

function couponDesignToSvg(design: any) {
  const d = design && typeof design === 'object' ? design : {};
  const width = 1200;
  const height = 600;
  const bgColor = String(d.bgColor || '#6366f1');
  const bgGradientEnd = String(d.bgGradientEnd || '#a855f7');
  const useGradient = !!d.useGradient;
  const angle = Number.isFinite(Number(d.bgGradientAngle))
    ? Number(d.bgGradientAngle)
    : 135;
  const borderRadius = Number.isFinite(Number(d.borderRadius))
    ? Number(d.borderRadius)
    : 32;
  const borderWidth =
    d.borderStyle && d.borderStyle !== 'none'
      ? Math.max(0, Number(d.borderWidth || 0))
      : 0;
  const borderColor = String(d.borderColor || '#ffffff40');
  const overlayOpacity = Math.max(
    0,
    Math.min(1, Number(d.overlayOpacity ?? 50) / 100),
  );
  const overlayColor = String(d.overlayColor || '#000000');
  const bgImage = String(d.bgImage || '').trim();
  const elements = Array.isArray(d.elements) ? d.elements : [];

  const radians = ((angle - 90) * Math.PI) / 180;
  const x1 = 50 + Math.cos(radians + Math.PI) * 50;
  const y1 = 50 + Math.sin(radians + Math.PI) * 50;
  const x2 = 50 + Math.cos(radians) * 50;
  const y2 = 50 + Math.sin(radians) * 50;

  const defs: string[] = [];
  if (useGradient) {
    defs.push(
      `<linearGradient id="bgGradient" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">` +
        `<stop offset="0%" stop-color="${escapeXml(bgColor)}" />` +
        `<stop offset="100%" stop-color="${escapeXml(bgGradientEnd)}" />` +
      `</linearGradient>`,
    );
  }

  const content: string[] = [];
  content.push(
    `<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}" fill="${
      useGradient ? 'url(#bgGradient)' : escapeXml(bgColor)
    }" />`,
  );

  if (bgImage) {
    content.push(
      `<image href="${escapeXml(bgImage)}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid slice" />`,
      `<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}" fill="${escapeXml(
        overlayColor,
      )}" opacity="${overlayOpacity}" />`,
    );
  }

  if (borderWidth > 0) {
    const inset = borderWidth / 2;
    content.push(
      `<rect x="${inset}" y="${inset}" width="${width - borderWidth}" height="${
        height - borderWidth
      }" rx="${Math.max(0, borderRadius - inset)}" ry="${Math.max(
        0,
        borderRadius - inset,
      )}" fill="none" stroke="${escapeXml(borderColor)}" stroke-width="${borderWidth}" />`,
    );
  }

  for (const el of elements) {
    const x = (Number(el?.x ?? 50) / 100) * width;
    const y = (Number(el?.y ?? 50) / 100) * height;
    const rotation = Number(el?.rotation ?? 0);
    const opacity = Math.max(0, Math.min(1, Number(el?.opacity ?? 100) / 100));

    if (el?.type === 'text') {
      const fontSize = Math.max(10, Number(el?.fontSize ?? 16) * 3);
      const fontFamily = escapeXml(el?.fontFamily || 'Inter');
      const fill = escapeXml(el?.color || '#ffffff');
      const fontWeight = el?.bold ? '700' : '400';
      const fontStyle = el?.italic ? 'italic' : 'normal';
      const textDecoration = el?.underline ? 'underline' : 'none';
      const anchor =
        el?.align === 'left'
          ? 'start'
          : el?.align === 'right'
            ? 'end'
            : 'middle';
      content.push(
        `<text x="${x}" y="${y}" fill="${fill}" font-family="${fontFamily}" font-size="${fontSize}" font-weight="${fontWeight}" font-style="${fontStyle}" text-decoration="${textDecoration}" text-anchor="${anchor}" dominant-baseline="middle" opacity="${opacity}" transform="rotate(${rotation} ${x} ${y})">${escapeXml(
          el?.content || '',
        )}</text>`,
      );
      continue;
    }

    if (el?.type === 'image' && el?.imageSrc) {
      const imageWidth = Math.max(24, Number(el?.imageWidth ?? 120) * 3);
      const imageHeight = Math.max(24, Number(el?.imageHeight ?? 120) * 3);
      const imageX = x - imageWidth / 2;
      const imageY = y - imageHeight / 2;
      content.push(
        `<image href="${escapeXml(el.imageSrc)}" x="${imageX}" y="${imageY}" width="${imageWidth}" height="${imageHeight}" opacity="${opacity}" transform="rotate(${rotation} ${x} ${y})" preserveAspectRatio="xMidYMid meet" />`,
      );
    }
  }

  const defsBlock = defs.length ? `<defs>${defs.join('')}</defs>` : '';
  return (
    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
    defsBlock +
    content.join('') +
    `</svg>`
  );
}

@Injectable()
export class CouponsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly uploads: UploadsService,
  ) {}

  private async generateCouponImageFromDesign(
    shopId: string,
    couponId: string,
    design: unknown,
  ) {
    const svg = couponDesignToSvg(design);
    return this.uploads.saveImage({
      shopId,
      entityType: 'coupon',
      entityId: couponId,
      purpose: 'active',
      ext: '.svg',
      buffer: Buffer.from(svg, 'utf8'),
    });
  }

  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;
    }
    // SUBADMIN is scoped to current shop only
    if (!user.shopId || user.shopId !== shopId)
      throw new ForbiddenException('Forbidden');
  }

  private async listAllowedShopIdsForUser(
    user: RequestUser,
  ): Promise<string[]> {
    if (user.role === UserRole.SUPERADMIN) {
      const shops = await this.prisma.shop.findMany({ select: { id: true } });
      return shops.map((s) => s.id);
    }
    if (user.role === UserRole.ADMIN) {
      const shops = await this.prisma.shop.findMany({
        where: { adminId: user.id },
        select: { id: true },
      });
      return shops.map((s) => s.id);
    }
    // SUBADMIN
    return user.shopId ? [user.shopId] : [];
  }

  private async createForShop(
    user: RequestUser,
    shopId: string,
    dto: CreateCouponDto,
    opts?: { tmpMode?: 'move' | 'copy' },
  ) {
    await this.assertCanAccessShop(user, shopId);

    // Plan limits: Coupons/month
    const shop = await this.prisma.shop.findUnique({
      where: { id: shopId },
      select: {
        admin: {
          select: {
            adminSubscription: {
              select: {
                plan: { select: { code: true, features: true } },
              },
            },
          },
        },
      },
    });
    if (!shop) throw new NotFoundException('Shop not found');
    const sharedPlan = shop.admin.adminSubscription?.plan;
    if (!sharedPlan) throw new ForbiddenException('Admin subscription is missing');
    const limit = parseMonthlyLimit(sharedPlan.features, 'Coupons');
    if (limit !== 'unlimited') {
      if (limit <= 0) {
        throw new PlanLimitException({
          featureKey: 'Coupons',
          currentPlanCode: sharedPlan.code,
          limit,
          used: 0,
          period: 'MONTH',
          message: 'Your plan does not allow creating coupons',
        });
      }
      const now = new Date();
      const start = new Date(now.getFullYear(), now.getMonth(), 1);
      const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
      const used = await this.prisma.coupon.count({
        where: { shopId, createdAt: { gte: start, lt: end } },
      });
      if (used >= limit) {
        throw new PlanLimitException({
          featureKey: 'Coupons',
          currentPlanCode: sharedPlan.code,
          limit,
          used,
          period: 'MONTH',
          message: `Coupon limit reached for your plan (limit: ${limit}/month)`,
        });
      }
    }

    if (
      dto.distributionBid != null &&
      (dto.distributionBid < 5 || dto.distributionBid > 30)
    ) {
      throw new BadRequestException('distributionBid must be between 5 and 30');
    }

    if (dto.valueType === CouponValueType.FIXED) {
      if (dto.valueFixed == null)
        throw new BadRequestException('valueFixed is required for FIXED');
    } else if (dto.valueType === CouponValueType.PERCENTAGE) {
      if (dto.valuePercent == null)
        throw new BadRequestException(
          'valuePercent is required for PERCENTAGE',
        );
      if (dto.valuePercent < 0 || dto.valuePercent > 100)
        throw new BadRequestException('valuePercent must be between 0 and 100');
    }

    const activeAt = parseDateOrNull(dto.activeAt ?? null, { endOfDay: false });
    const expiresAt = parseDateOrNull(dto.expiresAt ?? null, {
      endOfDay: true,
    });
    assertDateOrder(activeAt, expiresAt);

    const nextStatus = dto.status ?? CouponStatus.DRAFT;
    if (nextStatus === CouponStatus.ACTIVE) {
      if (!dto.imageUrl || dto.imageUrl.trim() === '') {
        throw new BadRequestException(
          'imageUrl is required when status is ACTIVE',
        );
      }
    }

    const cashiCoinsCost = Math.max(
      0,
      Number((dto as any).cashiCoinsCost ?? (dto as any).cashiPointsCost ?? 0),
    );

    const created = await this.prisma.coupon.create({
      data: {
        shopId,
        title: dto.title,
        shortDescription: dto.shortDescription ?? null,
        longDescription: dto.longDescription ?? null,
        cashiCoinsCost,
        valueType: dto.valueType,
        valueFixed:
          dto.valueType === CouponValueType.FIXED ? dto.valueFixed! : null,
        valuePercent:
          dto.valueType === CouponValueType.PERCENTAGE
            ? (dto.valuePercent as any)
            : null,
        minOrderValue: dto.minOrderValue ?? null,
        totalCoupons: dto.totalCoupons ?? null,
        distributionBid: dto.distributionBid ?? null,
        status: dto.status ?? undefined,
        moderationStatus: ModerationStatus.DRAFT,
        submittedAt: null,
        publishedAt: null,
        rejectedAt: null,
        rejectedReason: null,
        activeAt,
        expiresAt,
        imageUrl: dto.imageUrl ?? null,
        design:
          nextStatus === CouponStatus.ACTIVE
            ? null
            : dto.design != null
              ? (dto.design as any)
              : null,
      },
    });

    const updates: any = {};
    if (created.design) {
      updates.design = await this.uploads.rewriteDesignAssets(
        shopId,
        created.design as any,
        {
          bgRelDir: `shops/${shopId}/coupons/${created.id}/background`,
          stickerRelDir: `shops/${shopId}/coupons/${created.id}/sticker`,
        },
        { mode: opts?.tmpMode ?? 'move' },
      );
    }
    if (
      created.imageUrl &&
      created.imageUrl.startsWith('/uploads/shops/') &&
      created.imageUrl.includes('/tmp/')
    ) {
      updates.imageUrl =
        (opts?.tmpMode ?? 'move') === 'copy'
          ? await this.uploads.copyIfTmp(
              created.imageUrl,
              `shops/${shopId}/coupons/${created.id}/active`,
            )
          : await this.uploads.moveIfTmp(
              created.imageUrl,
              `shops/${shopId}/coupons/${created.id}/active`,
            );
    }
    if (Object.keys(updates).length > 0) {
      return this.prisma.coupon.update({
        where: { id: created.id },
        data: updates,
      });
    }
    return created;
  }

  async list(user: RequestUser, shopIdQuery?: string) {
    if (user.role === UserRole.SUPERADMIN) {
      return this.prisma.coupon.findMany({
        where: shopIdQuery ? { shopId: shopIdQuery } : undefined,
        orderBy: { createdAt: 'desc' },
      });
    }
    if (!user.shopId) throw new ForbiddenException('No shop selected');
    await this.assertCanAccessShop(user, user.shopId);
    return this.prisma.coupon.findMany({
      where: { shopId: user.shopId },
      orderBy: { createdAt: 'desc' },
    });
  }

  async listPaged(
    user: RequestUser,
    args: {
      page: number;
      limit: number;
      q?: string;
      status?: string;
      moderationStatus?: 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 q = String(args.q ?? '').trim();
    const statusRaw =
      args.status != null ? String(args.status).trim().toUpperCase() : '';
    const modRaw =
      args.moderationStatus != null
        ? String(args.moderationStatus).trim().toUpperCase()
        : '';

    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);

    let status: CouponStatus | undefined;
    if (statusRaw === 'DRAFT') status = CouponStatus.DRAFT;
    else if (statusRaw === 'ACTIVE') status = CouponStatus.ACTIVE;
    else if (statusRaw === 'EXPIRED') status = CouponStatus.EXPIRED;

    let moderationStatus: ModerationStatus | undefined;
    if (modRaw === 'DRAFT') moderationStatus = ModerationStatus.DRAFT;
    else if (modRaw === 'SUBMITTED')
      moderationStatus = ModerationStatus.SUBMITTED;
    else if (modRaw === 'PUBLISHED')
      moderationStatus = ModerationStatus.PUBLISHED;
    else if (modRaw === 'REJECTED')
      moderationStatus = ModerationStatus.REJECTED;

    const where: Prisma.CouponWhereInput = { shopId };
    if (status) where.status = status;
    if (moderationStatus) where.moderationStatus = moderationStatus;
    if (q) {
      where.OR = [
        { id: { contains: q, mode: 'insensitive' } },
        { title: { contains: q, mode: 'insensitive' } },
        { shortDescription: { contains: q, mode: 'insensitive' } },
        { longDescription: { contains: q, mode: 'insensitive' } },
      ];
    }

    const [total, items] = await this.prisma.$transaction([
      this.prisma.coupon.count({ where }),
      this.prisma.coupon.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip,
        take: limit,
      }),
    ]);

    return { page, limit, total, items };
  }

  async listAdminPaged(args: {
    page: number;
    limit: number;
    q?: string;
    shopId?: string;
    status?: string;
    moderationStatus?: 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 q = String(args.q ?? '').trim();
    const shopId = String(args.shopId ?? '').trim();
    const statusRaw =
      args.status != null ? String(args.status).trim().toUpperCase() : '';
    const modRaw =
      args.moderationStatus != null
        ? String(args.moderationStatus).trim().toUpperCase()
        : '';

    let status: CouponStatus | undefined;
    if (statusRaw === 'DRAFT') status = CouponStatus.DRAFT;
    else if (statusRaw === 'ACTIVE') status = CouponStatus.ACTIVE;
    else if (statusRaw === 'EXPIRED') status = CouponStatus.EXPIRED;

    let moderationStatus: ModerationStatus | undefined;
    if (modRaw === 'DRAFT') moderationStatus = ModerationStatus.DRAFT;
    else if (modRaw === 'SUBMITTED')
      moderationStatus = ModerationStatus.SUBMITTED;
    else if (modRaw === 'PUBLISHED')
      moderationStatus = ModerationStatus.PUBLISHED;
    else if (modRaw === 'REJECTED')
      moderationStatus = ModerationStatus.REJECTED;

    const where: Prisma.CouponWhereInput = {};
    if (shopId) where.shopId = shopId;
    if (status) where.status = status;
    if (moderationStatus) where.moderationStatus = moderationStatus;
    if (q) {
      where.OR = [
        { id: { contains: q, mode: 'insensitive' } },
        { title: { contains: q, mode: 'insensitive' } },
        { shortDescription: { contains: q, mode: 'insensitive' } },
        { longDescription: { contains: q, mode: 'insensitive' } },
        { shop: { name: { contains: q, mode: 'insensitive' } } },
      ];
    }

    const [total, items] = await this.prisma.$transaction([
      this.prisma.coupon.count({ where }),
      this.prisma.coupon.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip,
        take: limit,
        select: {
          id: true,
          shopId: true,
          title: true,
          shortDescription: true,
          longDescription: true,
          cashiCoinsCost: true,
          valueType: true,
          valueFixed: true,
          valuePercent: true,
          minOrderValue: true,
          totalCoupons: true,
          distributionBid: true,
          status: true,
          moderationStatus: true,
          resubmissionCount: true,
          publishedAt: true,
          rejectedAt: true,
          rejectedReason: true,
          activeAt: true,
          expiresAt: true,
          imageUrl: true,
          design: true,
          issuedCount: true,
          redeemedCount: true,
          createdAt: true,
          updatedAt: true,
          shop: {
            select: {
              id: true,
              name: true,
              username: true,
              city: true,
              state: true,
            },
          },
        },
      }),
    ]);

    return { page, limit, total, items };
  }

  async publish(
    id: string,
    dto?: { cashiPointsCost?: number; cashiCoinsCost?: number },
  ) {
    const existing = await this.prisma.coupon.findUnique({
      where: { id },
      select: { id: true, status: true, imageUrl: true },
    });
    if (!existing) throw new NotFoundException('Coupon not found');
    if (!existing.imageUrl || String(existing.imageUrl).trim() === '') {
      throw new BadRequestException(
        'Coupon image is missing. Submit or resubmit the coupon from Cashi Backend so the image is generated first.',
      );
    }

    const now = new Date();
    const nextCostRaw =
      dto && (dto as any).cashiPointsCost !== undefined
        ? (dto as any).cashiPointsCost
        : dto && (dto as any).cashiCoinsCost !== undefined
          ? (dto as any).cashiCoinsCost
          : undefined;
    const nextCost =
      nextCostRaw !== undefined ? Math.max(0, Number(nextCostRaw ?? 0)) : null;
    if (nextCostRaw !== undefined && !Number.isFinite(nextCost!)) {
      throw new BadRequestException('Invalid cashiPointsCost');
    }
    const updated = await this.prisma.coupon.update({
      where: { id },
      data: {
        moderationStatus: ModerationStatus.PUBLISHED,
        publishedAt: now,
        rejectedAt: null,
        rejectedReason: null,
        status: CouponStatus.ACTIVE,
        activeAt: { set: now },
        ...(nextCost != null ? { cashiCoinsCost: nextCost } : {}),
      },
      select: {
        id: true,
        status: true,
        moderationStatus: true,
        publishedAt: true,
        cashiCoinsCost: true,
      },
    });
    return { ok: true, coupon: updated };
  }

  async reject(id: string, dto: RejectCouponDto) {
    const existing = await this.prisma.coupon.findUnique({
      where: { id },
      select: { id: true },
    });
    if (!existing) throw new NotFoundException('Coupon not found');
    const reason = String(dto?.reason ?? '').trim();
    if (!reason) throw new BadRequestException('reason is required');

    const now = new Date();
    const updated = await this.prisma.coupon.update({
      where: { id },
      data: {
        moderationStatus: ModerationStatus.REJECTED,
        submittedAt: null,
        rejectedAt: now,
        rejectedReason: reason,
        publishedAt: null,
        status: CouponStatus.DRAFT,
        activeAt: null,
      },
      select: {
        id: true,
        status: true,
        moderationStatus: true,
        rejectedAt: true,
        rejectedReason: true,
      },
    });
    return { ok: true, coupon: updated };
  }

  async submit(user: RequestUser, id: string) {
    const existing = await this.prisma.coupon.findUnique({
      where: { id },
      select: {
        id: true,
        shopId: true,
        moderationStatus: true,
        imageUrl: true,
        design: true,
      },
    });
    if (!existing) throw new NotFoundException('Coupon not found');
    await this.assertCanAccessShop(user, existing.shopId);
    if (existing.moderationStatus === ModerationStatus.PUBLISHED) {
      throw new BadRequestException('Coupon is already published');
    }
    const generatedImageUrl = existing.design
      ? await this.generateCouponImageFromDesign(
          existing.shopId,
          existing.id,
          existing.design,
        )
      : null;
    const finalImageUrl = generatedImageUrl || existing.imageUrl;
    if (!finalImageUrl || String(finalImageUrl).trim() === '') {
      throw new BadRequestException(
        'Coupon image is missing and no design is available to generate it.',
      );
    }
    const now = new Date();
    const updated = await this.prisma.coupon.update({
      where: { id },
      data: {
        imageUrl: finalImageUrl,
        moderationStatus: ModerationStatus.SUBMITTED,
        submittedAt: now,
        rejectedAt: null,
        rejectedReason: null,
        ...(existing.moderationStatus === ModerationStatus.REJECTED
          ? { resubmissionCount: { increment: 1 } }
          : {}),
      },
      select: {
        id: true,
        moderationStatus: true,
        submittedAt: true,
        resubmissionCount: true,
      },
    });
    return { ok: true, coupon: updated };
  }

  async listCouponCustomers(user: RequestUser, id: string) {
    const coupon = await this.prisma.coupon.findUnique({
      where: { id },
      select: {
        id: true,
        shopId: true,
        title: true,
        moderationStatus: true,
        issuedCount: true,
        redeemedCount: true,
      },
    });
    if (!coupon) throw new NotFoundException('Coupon not found');
    await this.assertCanAccessShop(user, coupon.shopId);

    const rows = await this.prisma.customerCoupon.findMany({
      where: {
        couponId: coupon.id,
        status: {
          in: [CustomerCouponStatus.ASSIGNED, CustomerCouponStatus.REDEEMED],
        },
      },
      orderBy: [{ redeemedAt: 'desc' }, { assignedAt: 'desc' }],
      select: {
        id: true,
        status: true,
        assignedAt: true,
        redeemedAt: true,
        customer: {
          select: {
            id: true,
            name: true,
            phone: true,
            email: true,
            role: true,
          },
        },
        redeemedSale: {
          select: {
            id: true,
            amount: true,
            createdAt: true,
          },
        },
      },
    });

    const customerIds = Array.from(
      new Set(
        rows
          .map((row) => row.customer?.id)
          .filter((customerId): customerId is string => !!customerId),
      ),
    );
    const customersWithCompletedSales = new Set(
      customerIds.length === 0
        ? []
        : (
            await this.prisma.sale.findMany({
              where: {
                shopId: coupon.shopId,
                customerId: { in: customerIds },
                status: SaleStatus.COMPLETED,
              },
              distinct: ['customerId'],
              select: { customerId: true },
            })
          ).map((sale) => sale.customerId),
    );

    const mapAssignment = (row: (typeof rows)[number]) => ({
      assignmentId: row.id,
      status: row.status,
      assignedAt: row.assignedAt,
      redeemedAt: row.redeemedAt,
      customer: row.customer
        ? {
            ...row.customer,
            hasStoreSale: customersWithCompletedSales.has(row.customer.id),
          }
        : null,
      redeemedSale: row.redeemedSale,
    });

    return {
      coupon,
      issued: rows
        .filter((row) => row.status === CustomerCouponStatus.ASSIGNED)
        .map(mapAssignment),
      redeemed: rows
        .filter((row) => row.status === CustomerCouponStatus.REDEEMED)
        .map(mapAssignment),
    };
  }

  async create(user: RequestUser, dto: CreateCouponDto) {
    if (!user.shopId) throw new ForbiddenException('No shop selected');
    return this.createForShop(user, user.shopId, dto, { tmpMode: 'move' });
  }

  async bulkCreate(
    user: RequestUser,
    dto: CreateCouponDto & { shopIds?: string[]; allShops?: boolean },
  ) {
    if (user.role === UserRole.SUBADMIN) {
      throw new ForbiddenException('Not allowed');
    }

    const allowed = await this.listAllowedShopIdsForUser(user);
    if (allowed.length === 0)
      throw new ForbiddenException('No shops available');

    let targets: string[] = [];
    if (dto.allShops) {
      targets = allowed;
    } else if (dto.shopIds && dto.shopIds.length > 0) {
      const set = new Set(allowed);
      targets = dto.shopIds.filter((id) => set.has(id));
    } else {
      // default to current shop
      if (!user.shopId) throw new ForbiddenException('No shop selected');
      targets = [user.shopId];
    }

    if (targets.length === 0)
      throw new ForbiddenException('No allowed shops selected');

    // Create sequentially (keeps logic simple & ensures per-shop validations)
    const created: any[] = [];
    for (const shopId of targets) {
      created.push(
        await this.createForShop(user, shopId, dto, { tmpMode: 'copy' }),
      );
    }
    return created;
  }

  async update(user: RequestUser, id: string, dto: UpdateCouponDto) {
    const existing = await this.prisma.coupon.findUnique({
      where: { id },
      select: {
        id: true,
        shopId: true,
        status: true,
        moderationStatus: true,
        imageUrl: true,
        valueType: true,
        valueFixed: true,
        valuePercent: true,
        distributionBid: true,
        activeAt: true,
        expiresAt: true,
      },
    });
    if (!existing) throw new NotFoundException('Coupon not found');
    await this.assertCanAccessShop(user, existing.shopId);

    // Once submitted/published, shop users must not edit.
    if (
      user.role !== UserRole.SUPERADMIN &&
      (existing.moderationStatus === ModerationStatus.SUBMITTED ||
        existing.moderationStatus === ModerationStatus.PUBLISHED)
    ) {
      throw new BadRequestException(
        'Submitted/Published coupons cannot be edited',
      );
    }

    // Once ACTIVE, design must not change.
    if (existing.status === CouponStatus.ACTIVE && dto.design !== undefined) {
      throw new BadRequestException(
        'Design cannot be changed when coupon is ACTIVE',
      );
    }

    const nextValueType = dto.valueType ?? existing.valueType;
    const nextFixed =
      dto.valueFixed === undefined ? existing.valueFixed : dto.valueFixed;
    const nextPercent =
      dto.valuePercent === undefined ? existing.valuePercent : dto.valuePercent;

    if (nextValueType === CouponValueType.FIXED) {
      if (nextFixed == null)
        throw new BadRequestException('valueFixed is required for FIXED');
    } else {
      if (nextPercent == null)
        throw new BadRequestException(
          'valuePercent is required for PERCENTAGE',
        );
      if (nextPercent < 0 || nextPercent > 100)
        throw new BadRequestException('valuePercent must be between 0 and 100');
    }

    const nextDistributionBid =
      dto.distributionBid === undefined
        ? existing.distributionBid
        : dto.distributionBid;
    if (
      nextDistributionBid != null &&
      (nextDistributionBid < 5 || nextDistributionBid > 30)
    ) {
      throw new BadRequestException('distributionBid must be between 5 and 30');
    }

    const nextActiveAt =
      dto.activeAt === undefined
        ? existing.activeAt
        : parseDateOrNull(dto.activeAt, { endOfDay: false });
    const nextExpiresAt =
      dto.expiresAt === undefined
        ? existing.expiresAt
        : parseDateOrNull(dto.expiresAt, { endOfDay: true });
    assertDateOrder(nextActiveAt, nextExpiresAt);

    const nextStatus = dto.status ?? existing.status;
    const nextImageUrl =
      dto.imageUrl === undefined ? existing.imageUrl : dto.imageUrl;
    if (nextStatus === CouponStatus.ACTIVE) {
      if (!nextImageUrl || nextImageUrl.trim() === '') {
        throw new BadRequestException(
          'imageUrl is required when status is ACTIVE',
        );
      }
    }

    const nextDesign =
      dto.design !== undefined && nextStatus !== CouponStatus.ACTIVE
        ? await this.uploads.rewriteDesignAssets(
            existing.shopId,
            dto.design as any,
            {
              bgRelDir: `shops/${existing.shopId}/coupons/${existing.id}/background`,
              stickerRelDir: `shops/${existing.shopId}/coupons/${existing.id}/sticker`,
            },
            { mode: 'move' },
          )
        : dto.design;

    const nextImageUrlMoved =
      dto.imageUrl !== undefined &&
      dto.imageUrl &&
      dto.imageUrl.startsWith(`/uploads/shops/${existing.shopId}/tmp/`)
        ? await this.uploads.moveIfTmp(
            dto.imageUrl,
            `shops/${existing.shopId}/coupons/${existing.id}/active`,
          )
        : dto.imageUrl;

    return this.prisma.coupon.update({
      where: { id },
      data: {
        ...(dto.title !== undefined ? { title: dto.title } : {}),
        ...(dto.shortDescription !== undefined
          ? { shortDescription: dto.shortDescription }
          : {}),
        ...(dto.longDescription !== undefined
          ? { longDescription: dto.longDescription }
          : {}),
        ...((dto as any).cashiCoinsCost !== undefined ||
        (dto as any).cashiPointsCost !== undefined
          ? {
              cashiCoinsCost: Math.max(
                0,
                Number(
                  (dto as any).cashiCoinsCost ?? (dto as any).cashiPointsCost ?? 0,
                ),
              ),
            }
          : {}),
        ...(dto.valueType !== undefined ? { valueType: dto.valueType } : {}),
        ...(dto.valueFixed !== undefined ? { valueFixed: dto.valueFixed } : {}),
        ...(dto.valuePercent !== undefined
          ? { valuePercent: dto.valuePercent as any }
          : {}),
        ...(dto.minOrderValue !== undefined
          ? { minOrderValue: dto.minOrderValue }
          : {}),
        ...(dto.totalCoupons !== undefined
          ? { totalCoupons: dto.totalCoupons }
          : {}),
        ...(dto.distributionBid !== undefined
          ? { distributionBid: dto.distributionBid }
          : {}),
        ...(dto.status !== undefined ? { status: dto.status } : {}),
        ...(dto.activeAt !== undefined ? { activeAt: nextActiveAt } : {}),
        ...(dto.expiresAt !== undefined ? { expiresAt: nextExpiresAt } : {}),
        ...(dto.imageUrl !== undefined ? { imageUrl: nextImageUrlMoved } : {}),
        ...(dto.design !== undefined
          ? {
              design:
                nextStatus === CouponStatus.ACTIVE ? null : (nextDesign as any),
            }
          : nextStatus === CouponStatus.ACTIVE
            ? { design: null }
            : {}),
      },
    });
  }

  async remove(user: RequestUser, id: string) {
    const existing = await this.prisma.coupon.findUnique({
      where: { id },
      select: { id: true, shopId: true, moderationStatus: true },
    });
    if (!existing) throw new NotFoundException('Coupon not found');
    await this.assertCanAccessShop(user, existing.shopId);
    if (
      user.role !== UserRole.SUPERADMIN &&
      (existing.moderationStatus === ModerationStatus.SUBMITTED ||
        existing.moderationStatus === ModerationStatus.PUBLISHED)
    ) {
      throw new BadRequestException(
        'Submitted/Published coupons cannot be deleted',
      );
    }
    await this.prisma.coupon.delete({ where: { id } });
    return { ok: true };
  }
}
