import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
  PublicHomeGoodsQueryDto,
  PublicQueryGoodDto,
  PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
import { PublicGoodDto } from './dto/public-good.dto';
import {
  PublicGoodDetailDto,
  PublicTagGroupFilterDto,
} from './dto/public-good-detail.dto';

export interface PublicPaginatedGoods {
  items: PublicGoodDto[];
  total: number;
  page: number;
  pageSize: number;
}

const PUBLIC_GOOD_INCLUDE = {
  country: true,
  category: true,
  tag: { include: { tagGroup: true } },
  position: true,
  originGood: {
    include: {
      detail: true,
      variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
      family: {
        select: {
          id: true,
          familyCode: true,
          familyName: true,
          sizeChart: true,
          packageSpecs: true,
          priceMatrix: true,
        },
      },
    },
  },
  mergedOriginGoods: {
    orderBy: { createdAt: 'asc' as const },
    include: {
      originGood: {
        include: {
          detail: true,
          variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
        },
      },
    },
  },
  goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude;

type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;

@Injectable()
export class PublicService {
  constructor(private readonly prisma: PrismaService) {}

  async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
    const goodsWhere: Prisma.GoodWhereInput = {
      familyId: { not: null },
      originGood: { delisted: false },
      ...(countryId ? { countryId: BigInt(countryId) } : {}),
    };
    const leafCategories = await this.prisma.category.findMany({
      where: { goods: { some: goodsWhere } },
      orderBy: { id: 'asc' },
    });
    const ancestorIds = new Set<bigint>();
    for (const leaf of leafCategories) {
      let cursor: bigint | null = leaf.parentCategoryId;
      while (cursor !== null && !ancestorIds.has(cursor)) {
        ancestorIds.add(cursor);
        const parent = await this.prisma.category.findUnique({
          where: { id: cursor },
          select: { id: true, parentCategoryId: true },
        });
        if (!parent) break;
        cursor = parent.parentCategoryId;
      }
    }
    const ancestorRows = ancestorIds.size
      ? await this.prisma.category.findMany({
          where: { id: { in: [...ancestorIds] } },
          orderBy: { id: 'asc' },
        })
      : [];
    const allRows = [...leafCategories, ...ancestorRows].filter(
      (row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
    );
    allRows.sort((a, b) => Number(a.id - b.id));
    const directCounts = await this.prisma.good.groupBy({
      by: ['categoryId'],
      where: goodsWhere,
      _count: { _all: true },
    });
    return this.buildTree(
      allRows,
      new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
    );
  }

  async getCountries(): Promise<PublicCountryDto[]> {
    const rows = await this.prisma.country.findMany({
      where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
      orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
    });
    return rows.map(PublicCountryDto.from);
  }

  async getTags(): Promise<PublicTagDto[]> {
    const rows = await this.prisma.tag.findMany({
      where: {
        goodTags: {
          some: { good: { familyId: { not: null }, originGood: { delisted: false } } },
        },
      },
      orderBy: [
        { tagGroup: { sortOrder: 'asc' } },
        { sortOrder: 'asc' },
        { id: 'asc' },
      ],
      include: { tagGroup: true },
    });
    return rows.map(PublicTagDto.from);
  }

  async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
    const goodWhere: Prisma.GoodWhereInput = {
      familyId: { not: null },
      originGood: { delisted: false },
      ...(countryId ? { countryId: BigInt(countryId) } : {}),
    };
    const rows = await this.prisma.tagGroup.findMany({
      where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
      orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
      include: {
        tags: {
          where: { goodTags: { some: { good: goodWhere } } },
          orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
          include: {
            _count: { select: { goodTags: { where: { good: goodWhere } } } },
          },
        },
      },
    });
    return rows.map((group) => ({
      ...PublicTagGroupDto.from(group),
      tags: group.tags.map((tag) => ({
        id: tag.id.toString(),
        tagName: tag.tagName,
        tagColor: tag.tagColor,
        tagFontColor: tag.tagFontColor,
        sortOrder: tag.sortOrder,
        productCount: tag._count.goodTags,
      })),
    }));
  }

  async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
    // 无族商品（自定义）不进公开列表：只认族
    const where: Prisma.GoodWhereInput = {
      familyId: { not: null },
      originGood: { delisted: false },
    };
    if (query.countryId) where.countryId = BigInt(query.countryId);
    if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
    if (query.categoryId) {
      where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
    }

    const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
    if (tagFilters.length) where.AND = tagFilters;

    const minPrice = this.parsePrice(query.minPrice, 'minPrice');
    const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
    if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
      throw new BadRequestException('minPrice 不能大于 maxPrice');
    }
    if (minPrice !== null || maxPrice !== null) {
      where.originGood = {
        delisted: false,
        goodPrice: {
          ...(minPrice !== null ? { gte: minPrice } : {}),
          ...(maxPrice !== null ? { lte: maxPrice } : {}),
        },
      };
    }

    const orderBy: Prisma.GoodOrderByWithRelationInput[] =
      query.sort === 'PRICE_ASC'
        ? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
        : query.sort === 'PRICE_DESC'
          ? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
          : query.sort === 'NEWEST'
            ? [{ createdAt: 'desc' }, { id: 'asc' }]
            : [
                { goodPriority: 'desc' },
                { position: { indexVal: 'asc' } },
                { createdAt: 'desc' },
                { id: 'asc' },
              ];

    // 契约族化：一族对外只暴露一条（代表行=排序第一条，goodId=族ID）；
    // 无族 Good（自定义商品）各自成一条。商品量级为百级，先取全量匹配
    // 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
    const rows = await this.prisma.good.findMany({
      where,
      include: PUBLIC_GOOD_INCLUDE,
      orderBy,
    });
    const grouped = new Map<string, PublicGoodRow[]>();
    for (const good of rows) {
      const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
      const bucket = grouped.get(key);
      if (bucket) bucket.push(good);
      else grouped.set(key, [good]);
    }
    let items = [...grouped.values()].map((goods) => {
      const rep = goods[0];
      const dto = this.toPublicGood(rep);
      // 列表价 = 族矩阵最低价（"这个款之下有哪些价格"的起价）；无矩阵回退链接价
      const familyMin = this.familyMinPrice(rep);
      if (rep.familyId && familyMin !== null) dto.price = familyMin;
      return dto;
    });
    if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') {
      // 分组后的最终价（族最低价）重排，null 价沉底
      const num = (v: string | null) => (v === null ? Number.POSITIVE_INFINITY : Number(v));
      items = items.sort((a, b) =>
        query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
      );
    }
    const total = items.length;
    const start = (query.page - 1) * query.pageSize;
    return {
      items: items.slice(start, start + query.pageSize),
      total,
      page: query.page,
      pageSize: query.pageSize,
    };
  }

  /** 族物化矩阵的最低价；无族/无矩阵返回 null */
  private familyMinPrice(good: PublicGoodRow): string | null {
    const matrix = good.originGood.family?.priceMatrix as
      | { rows?: Array<{ price: string }> }
      | null
      | undefined;
    const prices = (matrix?.rows ?? []).map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
    return prices.length ? String(Math.min(...prices)) : null;
  }

  /**
   * 详情寻址契约（族化后）：goodId 即族 ID；无族商品（自定义）不对外暴露。
   * 族不存在、或族下没有任何在售商品配置（未配置/已下架）→ 404。
   */
  async getGood(goodId: string): Promise<PublicGoodDetailDto> {
    const notFound = () =>
      new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
    if (!/^\d+$/.test(goodId)) throw notFound();
    const detail = await this.getGoodByFamilyId(BigInt(goodId));
    if (!detail) throw notFound();
    return detail;
  }

  /** 族视角详情：代表 Good 提供公共字段（名称/主图/国家/分类），变体取全体成员并集 */
  private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
    const [family, goods] = await Promise.all([
      this.prisma.productFamily.findUnique({ where: { id: familyId } }),
      this.prisma.good.findMany({
        where: { familyId, originGood: { delisted: false } },
        include: PUBLIC_GOOD_INCLUDE,
        orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }, { id: 'asc' }],
      }),
    ]);
    // 族不存在、或族下没有任何在售商品配置（未配置/已下架）→ 走回退路径
    if (!family || goods.length === 0) return null;
    const rep = goods[0];

    const members = await this.prisma.originGood.findMany({
      where: { familyId, delisted: false },
      orderBy: { id: 'asc' },
      select: {
        id: true,
        detail: true,
        variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
      },
    });
    const familyVariants = members.flatMap((m) =>
      m.variants.map((variant) => ({ originGoodId: m.id, variant })),
    );
    const familyDetails = members
      .filter((m) => m.id !== rep.originGoodId)
      .map((m) => m.detail);

    const dto = this.toPublicGoodDetail(rep, familyVariants, familyDetails);
    // 尺码表/包装规格以族物化并集为准（款级公共数据），空并集回退主链接合并结果
    if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'false') {
      dto.sizeChart = (family.sizeChart as PublicGoodDetailDto['sizeChart']) ?? dto.sizeChart;
      dto.packageSpecs =
        (family.packageSpecs as PublicGoodDetailDto['packageSpecs']) ?? dto.packageSpecs;
    }
    dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category);
    return dto;
  }

  async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
    const rows = await this.prisma.good.findMany({
      where: {
        positionId: { not: null },
        familyId: { not: null },
        originGood: { delisted: false },
        ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
      },
      include: PUBLIC_GOOD_INCLUDE,
      orderBy: [
        { position: { indexVal: 'asc' } },
        { goodPriority: 'desc' },
        { id: 'asc' },
      ],
      take: query.limit,
    });
    // 首页同样按族去重（同族多条位置配置只保留排序最前一条），再截取 limit
    const seen = new Set<string>();
    const items: PublicGoodDto[] = [];
    for (const good of rows) {
      const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
      if (seen.has(key)) continue;
      seen.add(key);
      const dto = this.toPublicGood(good);
      const familyMin = this.familyMinPrice(good);
      if (good.familyId && familyMin !== null) dto.price = familyMin;
      items.push(dto);
    }
    return items.slice(0, query.limit);
  }

  private toPublicGood(good: PublicGoodRow): PublicGoodDto {
    const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
      group
        ? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
        : null;
    return {
      // 有族 → 族ID（对外契约）；无族（自定义商品）→ sdsGoodId
      goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId,
      goodName: good.goodName,
      goodPriority: good.goodPriority,
      country: {
        id: good.country.id.toString(),
        countryName: good.country.countryName,
        countryIcon: good.country.countryIcon,
      },
      category: {
        id: good.category.id.toString(),
        categoryName: good.category.categoryName,
        categoryIcon: good.category.categoryIcon,
      },
      tag: good.tag
        ? {
            id: good.tag.id.toString(),
            tagName: good.tag.tagName,
            tagColor: good.tag.tagColor,
            tagFontColor: good.tag.tagFontColor,
            group: formatGroup(good.tag.tagGroup),
          }
        : null,
      tags: good.goodTags.map(({ tag }) => ({
        id: tag.id.toString(),
        tagName: tag.tagName,
        tagColor: tag.tagColor,
        tagFontColor: tag.tagFontColor,
        group: formatGroup(tag.tagGroup),
      })),
      position: good.position
        ? { id: good.position.id.toString(), indexVal: good.position.indexVal }
        : null,
      image: good.goodImage ?? good.originGood.goodImage,
      price: good.originGood.goodPrice?.toString() ?? null,
      createdAt: good.createdAt.toISOString(),
    };
  }

  /** Group distinct variant images by color so the frontend can switch media per color.
   *  Only color-specific photos are included (main / result / detail images);
   *  design-layer素材图 and the product-level blank garment photo are excluded
   *  because they are not per-color gallery photos. */
  private groupImagesByColor(
    variants: Array<PublicGoodRow['originGood']['variants'][number]>,
  ): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
    const groups = new Map<string, {
      colorId: string | null;
      colorName: string | null;
      colorHex: string | null;
      images: string[];
    }>();
    for (const variant of variants) {
      const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
      let group = groups.get(key);
      if (!group) {
        group = {
          colorId: variant.colorId,
          colorName: variant.colorName,
          colorHex: variant.colorHex,
          images: [],
        };
        groups.set(key, group);
      }
      const design = (variant.designData ?? {}) as {
        detailImgUrls?: Array<{ imageUrl?: unknown }>;
        prototypeResultGroups?: Array<{ resultImage?: unknown }>;
      };
      const urls: unknown[] = [
        variant.imageUrl,
        ...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
        ...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
      ];
      for (const url of urls) {
        const value = typeof url === 'string' ? url.trim() : '';
        if (value && !group.images.includes(value)) {
          group.images.push(value);
        }
      }
    }
    return [...groups.values()];
  }

  /** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
  private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
    if (category.categoryIcon) return category.categoryIcon;
    let cursor = category.parentCategoryId;
    for (let depth = 0; cursor !== null && depth < 10; depth++) {
      const parent = await this.prisma.category.findUnique({
        where: { id: cursor },
        select: { categoryIcon: true, parentCategoryId: true },
      });
      if (!parent) break;
      if (parent.categoryIcon) return parent.categoryIcon;
      cursor = parent.parentCategoryId;
    }
    return null;
  }

  private toPublicGoodDetail(
    good: PublicGoodRow,
    familyVariants: Array<{
      originGoodId: bigint;
      variant: PublicGoodRow['originGood']['variants'][number];
    }> = [],
    /** 族成员 detail（款级公共规格并集来源之一；族视角详情传入，链接视角为空） */
    familyDetails: Array<PublicGoodRow['originGood']['detail']> = [],
  ): PublicGoodDetailDto {
    const base = this.toPublicGood(good);
    const detail = good.originGood.detail;
    // Detail specs filled from secondaries (族成员 + 旧副源) for sizes/options
    // the primary does not have.
    const secondaryDetails = [
      ...familyDetails,
      ...good.mergedOriginGoods.map((m) => m.originGood.detail),
    ].filter((d): d is NonNullable<typeof d> => Boolean(d));
    // 变体并集：主链接 ∪ 族成员（新机制）∪ 旧副源（过渡期）；
    // 同一链接可能既是族成员又挂旧副源，先按 `${originGoodId}:${sdsVariantId}` 去重，
    // 再按 color+size 去重（先到先得），避免站点出现重复的尺码×颜色行。
    const seen = new Set<string>(['']);
    const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => {
      const key = `${originGoodId}:${variant.sdsVariantId}`;
      if (seen.has(key)) return null;
      seen.add(key);
      return variant;
    };
    good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v));
    const unionVariants = [
      ...good.originGood.variants,
      ...familyVariants
        .map(({ originGoodId, variant }) => dedupe(originGoodId, variant))
        .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
      ...good.mergedOriginGoods.flatMap((m) =>
        m.originGood.variants
          .map((variant) => dedupe(m.originGoodId, variant))
          .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
      ),
    ];
    const allVariants = this.dedupeVariants(unionVariants);
    return {
      ...base,
      productCode: detail?.productCode ?? null,
      englishName: detail?.englishName ?? null,
      productionCycleHours: detail?.productionCycleHours ?? null,
      minWeightG: detail?.minWeightG?.toString() ?? null,
      details: {
        reminder: detail?.reminder ?? null,
        productionProcess: detail?.productionProcess ?? null,
        materialDescription: detail?.materialDescription ?? null,
        productPerformance: detail?.productPerformance ?? null,
        applicableScenarios: detail?.applicableScenarios ?? null,
        washingInstructions: detail?.washingInstructions ?? null,
        specialDescription: detail?.specialDescription ?? null,
        designExplanation: detail?.designExplanation ?? null,
        designArea: detail?.designArea ?? null,
        pictureRequest: detail?.pictureRequest ?? null,
      },
      media: this.mergeMedia(detail, secondaryDetails),
      mediaByColor: this.groupImagesByColor(allVariants),
      options: this.mergeOptions(detail, secondaryDetails),
      sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'),
      packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'),
      variants: allVariants.map((variant) => ({
        id: variant.sdsVariantId,
        sku: variant.sku,
        sizeId: variant.sizeId,
        sizeName: variant.sizeName,
        colorId: variant.colorId,
        colorName: variant.colorName,
        colorHex: variant.colorHex,
        imageUrl: variant.imageUrl,
        price: variant.price?.toString() ?? null,
        originalPrice: variant.originalPrice?.toString() ?? null,
        weightG: variant.weightG?.toString() ?? null,
        boxLengthCm: variant.boxLengthCm?.toString() ?? null,
        boxWidthCm: variant.boxWidthCm?.toString() ?? null,
        boxHeightCm: variant.boxHeightCm?.toString() ?? null,
        enabled: variant.enabled,
        sortOrder: variant.sortOrder,
      })),
      detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
      ...this.familyBlock(good),
    };
  }

  /**
   * 族块（设计 D4：零新增公开端点）：默认输出；仅当 PUBLIC_DETAIL_FROM_FAMILY
   * 显式设为 'false' 时关闭（应急回退开关）。数据全部来自 ProductFamily 的物化 JSON。
   */
  private familyBlock(
    good: PublicGoodRow,
  ): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
    if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') return {};
    const family = good.originGood.family;
    if (!family || !family.priceMatrix) return {};
    const matrix = family.priceMatrix as {
      sizes: Array<{ key: string; name: string | null }>;
      colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
      printCounts: string[];
      crafts: string[];
      logistics: string[];
      rows: Array<{ price: string }>;
    };
    const prices = matrix.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
    return {
      family: {
        familyId: family.id.toString(),
        familyCode: family.familyCode,
        familyName: family.familyName,
        sizes: matrix.sizes,
        colors: matrix.colors,
        printCounts: matrix.printCounts ?? [],
        crafts: matrix.crafts,
        logistics: matrix.logistics,
        sizeChart: (family.sizeChart as Record<string, unknown> | null) ?? null,
        packageSpecs: (family.packageSpecs as Record<string, unknown> | null) ?? null,
        priceMatrix: matrix as unknown as Record<string, unknown>,
        minPrice: prices.length ? String(Math.min(...prices)) : null,
      },
    };
  }

  /** Norm key for variant/dedupe matching: case-insensitive, trimmed. */
  private static normName(value: string | null | undefined): string {
    return (value ?? '').trim().toLowerCase();
  }

  /** Dedupe variants by color+size (case-insensitive); first (primary) wins. */
  private dedupeVariants(variants: PublicGoodRow['originGood']['variants']) {
    const seen = new Set<string>();
    const result: PublicGoodRow['originGood']['variants'] = [];
    for (const variant of variants) {
      const key = `${PublicService.normName(variant.colorName)}|${PublicService.normName(variant.sizeName)}`;
      if (seen.has(key)) continue;
      seen.add(key);
      result.push(variant);
    }
    return result;
  }

  /**
   * Merge a `{ rows: [...] }` spec (sizeChart / packageSpecs) across primary
   * and secondary details: primary rows win, rows for sizes the primary
   * lacks are appended from secondaries in order.
   */
  private mergeRowsByKey(
    primary: PublicGoodRow['originGood']['detail'],
    secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
    field: 'sizeChart' | 'packageSpecs',
  ): Record<string, unknown> | null {
    const primaryRows = (primary?.[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
    if (!Array.isArray(primaryRows) && secondaries.length === 0) return null;
    const rows: Array<Record<string, unknown>> = Array.isArray(primaryRows) ? [...primaryRows] : [];
    const seen = new Set(rows.map((r) => PublicService.normName(String(r?.sizeName ?? ''))));
    for (const sec of secondaries) {
      const secRows = (sec[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
      if (!Array.isArray(secRows)) continue;
      for (const row of secRows) {
        const key = PublicService.normName(String(row?.sizeName ?? ''));
        if (seen.has(key)) continue;
        seen.add(key);
        rows.push(row);
      }
    }
    if (rows.length === 0) return null;
    return { ...(primary?.[field] as Record<string, unknown> | null ?? {}), rows };
  }

  /**
   * Merge options across details: sizes/colors are unioned by name,
   * primary entries win and secondary-only ones are appended.
   */
  private mergeOptions(
    primary: PublicGoodRow['originGood']['detail'],
    secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
  ): Record<string, unknown> | null {
    const primaryOptions = primary?.options as
      | { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
      | null;
    if (!primaryOptions && secondaries.length === 0) return null;
    const merged: Record<string, unknown> = { ...(primaryOptions ?? {}) };
    for (const listKey of ['sizes', 'colors'] as const) {
      const base = Array.isArray(primaryOptions?.[listKey])
        ? [...(primaryOptions![listKey] as Array<Record<string, unknown>>)]
        : null;
      if (!base && secondaries.length === 0) continue;
      const rows = base ?? [];
      const seen = new Set(rows.map((r) => PublicService.normName(String(r?.name ?? ''))));
      for (const sec of secondaries) {
        const secOptions = sec.options as
          | { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
          | null;
        const secRows = secOptions?.[listKey];
        if (!Array.isArray(secRows)) continue;
        for (const row of secRows) {
          const key = PublicService.normName(String(row?.name ?? ''));
          if (seen.has(key)) continue;
          seen.add(key);
          rows.push(row);
        }
      }
      merged[listKey] = rows;
    }
    return merged;
  }

  /**
   * Merge the gallery `media` across details: primary images first,
   * secondary-only image URLs appended (URL-deduped). `primaryImageUrl`
   * stays the primary's. Image entries keep their original shape
   * (`{id,url,sortOrder}` objects or plain strings).
   */
  private mergeMedia(
    primary: PublicGoodRow['originGood']['detail'],
    secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
  ): Record<string, unknown> | null {
    const imageUrl = (img: unknown): string | null => {
      if (typeof img === 'string') return img;
      if (img && typeof img === 'object' && typeof (img as { url?: unknown }).url === 'string') {
        return (img as { url: string }).url;
      }
      return null;
    };
    const primaryMedia = (primary?.media as Record<string, unknown> | null) ?? null;
    const primaryImages = Array.isArray(primaryMedia?.images)
      ? (primaryMedia!.images as unknown[])
      : null;
    if (!primaryImages && secondaries.length === 0) return null;
    const images: unknown[] = primaryImages ? [...primaryImages] : [];
    const seen = new Set(images.map(imageUrl).filter((u): u is string => Boolean(u)));
    for (const sec of secondaries) {
      const secMedia = sec.media as { images?: unknown } | null;
      if (!Array.isArray(secMedia?.images)) continue;
      for (const img of secMedia!.images as unknown[]) {
        const url = imageUrl(img);
        if (!url || seen.has(url)) continue;
        seen.add(url);
        images.push(img);
      }
    }
    if (images.length === 0) return null;
    return { ...(primaryMedia ?? {}), images };
  }

  private async buildTagGroupFilters(
    selectedGroups: PublicTagFilterDto[],
  ): Promise<Prisma.GoodWhereInput[]> {
    const selections = selectedGroups.flatMap((group) => {
      if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
        throw new BadRequestException(
          'tags 每个元素必须包含合法的 tagGroupId 和 tagIds',
        );
      }
      return group.tagIds.map((tagId) => {
        if (!/^\d+$/.test(tagId)) {
          throw new BadRequestException('tagIds 必须全部为数字字符串');
        }
        return { tagGroupId: group.tagGroupId, tagId };
      });
    });
    const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
    const selected = uniqueTagIds.length
      ? await this.prisma.tag.findMany({
          where: { id: { in: uniqueTagIds.map((id) => BigInt(id)) } },
          select: { id: true, tagGroupId: true },
        })
      : [];
    if (selected.length !== uniqueTagIds.length) {
      throw new BadRequestException('包含不存在的标签 ID');
    }
    const actualGroups = new Map(
      selected.map((tag) => [
        tag.id.toString(),
        tag.tagGroupId?.toString() ?? null,
      ]),
    );
    for (const selection of selections) {
      if (actualGroups.get(selection.tagId) !== selection.tagGroupId) {
        throw new BadRequestException(
          `标签 ${selection.tagId} 不属于标签组 ${selection.tagGroupId}`,
        );
      }
    }
    const byGroup = new Map<string, bigint[]>();
    for (const selection of selections) {
      const key = selection.tagGroupId;
      const ids = byGroup.get(key) ?? [];
      const id = BigInt(selection.tagId);
      if (!ids.includes(id)) ids.push(id);
      byGroup.set(key, ids);
    }
    return [...byGroup.values()].map((ids) => ({
      goodTags: { some: { tagId: { in: ids } } },
    }));
  }

  private parsePrice(value: string | undefined, field: string): number | null {
    if (value === undefined || value === '') return null;
    const parsed = Number(value);
    if (!Number.isFinite(parsed) || parsed < 0) {
      throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
    }
    return parsed;
  }

  private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
    const ids: bigint[] = [rootId];
    let frontier: bigint[] = [rootId];
    while (frontier.length) {
      const children = await this.prisma.category.findMany({
        where: { parentCategoryId: { in: frontier } },
        select: { id: true },
      });
      if (!children.length) break;
      frontier = children.map((child) => child.id);
      ids.push(...frontier);
    }
    return ids;
  }

  private buildTree(
    rows: PrismaCategory[],
    directCounts: Map<bigint, number>,
  ): PublicCategoryNodeDto[] {
    const byId = new Map<bigint, PublicCategoryNodeDto>();
    for (const row of rows) {
      const node = PublicCategoryNodeDto.from(row, []);
      node.productCount = directCounts.get(row.id) ?? 0;
      byId.set(row.id, node);
    }
    const roots: PublicCategoryNodeDto[] = [];
    for (const row of rows) {
      const node = byId.get(row.id)!;
      const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
      if (parent) parent.children.push(node);
      else roots.push(node);
    }
    const total = (node: PublicCategoryNodeDto): number => {
      node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
      return node.productCount;
    };
    roots.forEach(total);
    return roots;
  }
}
