import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
import {
  TraceGraphDetailField,
  TraceGraphDetailLabels,
  TraceGraphNode,
} from "./buildTraceGraphNodes";
import {
  buildProductionGraphNodes,
  ProductionGraphLabels,
} from "./buildProductionGraphNodes";
import {
  groupQcResultsBySession,
} from "./traceLabelUtils";
import { formatQty, formatSignedQty } from "./traceQtyUtils";
import {
  createMaterialPickNode,
  docLinkFromOriginType,
  docLinkFromRefType,
  field,
  fieldIf,
  detailsOf,
  isJoProducedMaterial,
  parseSortKey,
} from "./traceNodeFactory";
import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils";
import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils";

export interface JoPreludeGraphLabels extends TraceGraphDetailLabels {
  nodeMaterialIn: string;
  nodeMaterialPick: string;
  nodePurchase: string;
  nodeReceipt: string;
  nodeJoCreated: string;
  pickOrder: string;
}

type LotPickRef = {
  pickOrderCode: string;
  pickOrderId: number | null;
  consoCode: string;
};

type PreludeBuildContext = {
  seenPurchaseLines: Set<number>;
  seenInboundLots: Set<number>;
  seenReceiptLots: Set<number>;
  seenQcKeys: Set<string>;
  seenPutawayKeys: Set<string>;
  pickOrderTargetDateMap: Map<string, string>;
  seq: number;
};

const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`;

const mergeLotPickMaps = (
  target: Map<string, LotPickRef[]>,
  source: Map<string, LotPickRef[]>,
): void => {
  source.forEach((picks, key) => {
    const list = target.get(key) ?? [];
    picks.forEach((p) => {
      if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) {
        list.push(p);
      }
    });
    if (list.length) target.set(key, list);
  });
};

const buildLotPickOrderMap = (
  materialInputs: ItemLotTraceJoPrelude["materialInputs"],
): Map<string, LotPickRef[]> => {
  const map = new Map<string, LotPickRef[]>();
  materialInputs.forEach((m) => {
    const code = m.pickOrderCode?.trim();
    if (!code) return;
    const key = materialLotKey(m.materialItemCode, m.materialLotNo);
    const list = map.get(key) ?? [];
    if (!list.some((p) => p.pickOrderCode === code)) {
      list.push({
        pickOrderCode: code,
        pickOrderId: m.pickOrderId,
        consoCode: m.consoCode,
      });
    }
    map.set(key, list);
    const nestedInputs = m.nestedJoPrelude?.materialInputs ?? [];
    if (nestedInputs.length) {
      mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs));
    }
  });
  return map;
};

const buildLotPickOrderMapRecursive = (
  materialInputs: ItemLotTraceMaterialInput[],
): Map<string, LotPickRef[]> => buildLotPickOrderMap(materialInputs);

const pickOrdersForLot = (
  map: Map<string, LotPickRef[]>,
  itemCode: string,
  lotNo: string,
): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? [];

const pickOrderCodesLabel = (picks: LotPickRef[]) =>
  picks.map((p) => p.pickOrderCode).join(" · ");

const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => {
  if (!picks.length) return base;
  const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`;
  return base ? `${base} · ${suffix}` : suffix;
};

const pickOrderDetailFields = (
  picks: LotPickRef[],
  pickLabel: string,
): TraceGraphDetailField[] =>
  picks.map((p) =>
    field(pickLabel, p.pickOrderCode, {
      linkKind: "pick",
      linkCode: p.pickOrderCode,
      linkId: p.pickOrderId,
      consoCode: p.consoCode || p.pickOrderCode,
    }),
  );

const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++;

const pickCtx = (ctx: PreludeBuildContext) => ({
  nextSeq: () => nextSeq(ctx),
  pickOrderTargetDate: (pickOrderCode: string) =>
    ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""),
});

const buildMaterialProductionNodes = (
  m: ItemLotTraceMaterialInput,
  labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
  ctx: PreludeBuildContext,
): TraceGraphNode[] => {
  const steps = m.productionSteps ?? [];
  if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return [];

  const matUom = m.materialUom?.trim() || "";
  return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({
    ...node,
    id: `mat-prod-${m.materialLotNo}-${node.id}`,
    traceLotNo: m.materialLotNo,
    traceItemCode: m.materialItemCode,
    subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "),
  }));
};

const buildMaterialStockInPreludeNodes = (
  m: ItemLotTraceMaterialInput,
  labels: JoPreludeGraphLabels,
  lotPicks: Map<string, LotPickRef[]>,
  ctx: PreludeBuildContext,
): TraceGraphNode[] => {
  const nodes: TraceGraphNode[] = [];
  const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo);
  const lotId = m.materialInventoryLotId;
  const origin = m.stockInOrigin;
  const matUom = m.materialUom?.trim() || "";

  (m.purchaseEvents ?? []).forEach((p) => {
    if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return;
    ctx.seenPurchaseLines.add(p.purchaseOrderLineId);
    const purchaseUnit = p.purchaseUnit;
    const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" ");
    const subtitle =
      [supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode;
    const meta = [supplierLabel].filter(Boolean).join(" · ");
    nodes.push({
      id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`,
      kind: "PURCHASE",
      timestamp: p.orderDate,
      sortKey: parseSortKey(p.orderDate, nextSeq(ctx)),
      title: labels.nodePurchase,
      subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder),
      qty: p.orderQty,
      uom: purchaseUnit,
      meta: meta || undefined,
      refType: "PO",
      refCode: p.purchaseOrderCode,
      refId: p.purchaseOrderId,
      docLinkKind: "po",
      traceLotNo: m.materialLotNo,
      traceItemCode: m.materialItemCode,
      categoryLabel: labels.categoryPurchase,
      details: [
        field(labels.detailItemCode, p.itemCode),
        field(labels.detailItemName, p.itemName),
        field(labels.detailSupplier, supplierLabel),
        field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)),
        field(labels.detailPurchaseUnit, p.purchaseUnit),
        field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, {
          linkKind: "po",
          linkCode: p.purchaseOrderCode,
          linkId: p.purchaseOrderId,
        }),
        field(labels.detailTime, p.orderDate),
        field(labels.detailLot, m.materialLotNo),
        ...pickOrderDetailFields(picks, labels.pickOrder),
      ],
    });
  });

  if (origin && lotId != null) {
    const originType = origin.type.trim().toUpperCase();
    const linkKind = docLinkFromOriginType(origin.type);
    const subtitle = [
      m.materialLotNo,
      [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "),
    ]
      .filter(Boolean)
      .join(" · ");

    if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) {
      ctx.seenReceiptLots.add(lotId);
      nodes.push({
        id: `mat-receipt-${lotId}-${origin.stockInLineId}`,
        kind: "RECEIPT",
        timestamp: origin.receiptDate,
        sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)),
        title: labels.nodeReceipt,
        subtitle: appendPickToSubtitle(
          [origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode,
          picks,
          labels.pickOrder,
        ),
        qty: origin.acceptedQty,
        uom: matUom,
        refType: origin.type,
        refCode: origin.refCode,
        refId: origin.refId,
        docLinkKind: linkKind,
        traceLotNo: m.materialLotNo,
        traceItemCode: m.materialItemCode,
        categoryLabel: labels.categoryReceipt,
        details: [
          field(labels.detailPurchaseOrderNo, origin.refCode, {
            linkKind,
            linkCode: origin.refCode,
            linkId: origin.refId,
          }),
          field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")),
          field(labels.detailItemCode, m.materialItemCode),
          field(labels.detailLot, m.materialLotNo),
          field(labels.detailQty, formatQty(origin.acceptedQty, matUom)),
          field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
          field(labels.detailTime, origin.receiptDate),
          ...pickOrderDetailFields(picks, labels.pickOrder),
        ],
      });
    } else if (
      originType !== "PO" &&
      originType !== "ADJ" &&
      !ctx.seenInboundLots.has(lotId)
    ) {
      // Nested JO_CREATED already represents this JO output lot (with 提料 links).
      const skipJoOriginBecauseNested =
        originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim());
      if (!skipJoOriginBecauseNested) {
        // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整).
        ctx.seenInboundLots.add(lotId);
        const isJoOrigin = originType === "JO";
        nodes.push({
          id: `mat-in-${lotId}-${origin.stockInLineId}`,
          kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN",
          timestamp: origin.receiptDate,
          sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)),
          title: isJoOrigin
            ? labels.nodeJoCreated
            : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`,
          subtitle: appendPickToSubtitle(
            subtitle || m.materialItemCode,
            picks,
            labels.pickOrder,
          ),
          qty: origin.acceptedQty,
          uom: matUom,
          refType: origin.type,
          refCode: origin.refCode,
          refId: origin.refId,
          docLinkKind: linkKind,
          traceLotNo: m.materialLotNo,
          traceItemCode: m.materialItemCode,
          categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase,
          details: [
            field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)),
            field(
              isJoOrigin
                ? labels.jobOrder
                : originType === "PO"
                  ? labels.detailPurchaseOrderNo
                  : labels.detailSourceDoc,
              origin.refCode,
              {
              linkKind,
              linkCode: origin.refCode,
              linkId: origin.refId,
            }),
            field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")),
            field(labels.detailItemCode, m.materialItemCode),
            field(labels.detailLot, m.materialLotNo),
            field(labels.detailQty, formatQty(origin.acceptedQty, matUom)),
            field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
            field(labels.detailTime, origin.receiptDate),
            ...pickOrderDetailFields(picks, labels.pickOrder),
          ],
        });
      }
    }
  }

  groupQcResultsBySession(m.qcResults).forEach((group) => {
    const first = group[0];
    if (!first) return;
    const key = `${first.stockInLineId}-${first.created}`;
    if (ctx.seenQcKeys.has(key)) return;
    ctx.seenQcKeys.add(key);
    const allPassed = group.every((q) => q.qcPassed);
    const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0);
    const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo]
      .filter(Boolean)
      .join(" · ");
    const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? "";
    nodes.push({
      id: `mat-qc-${key}`,
      kind: "MATERIAL_QC",
      timestamp: first.created,
      sortKey: parseSortKey(first.created, nextSeq(ctx)),
      title: allPassed ? labels.nodeQcPass : labels.nodeQcFail,
      subtitle: "",
      qty: totalFail,
      uom: matUom,
      meta: meta || undefined,
      traceLotNo: m.materialLotNo,
      traceItemCode: m.materialItemCode,
      categoryLabel: labels.categoryQc,
      qcTypeLabel: labels.tr.qcType(qcTypeRaw),
      details: detailsOf(
        field(labels.detailItemCode, m.materialItemCode),
        field(labels.detailLot, m.materialLotNo),
        field(labels.detailStatus, labels.tr.qcPassed(allPassed)),
        field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)),
        field(labels.detailQcCriteria, "", {
          variant: "qcCriteriaList",
          qcCriteriaItems: group.map((q) => ({
            name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem,
            description: q.qcItemDescription?.trim() || undefined,
            passed: q.qcPassed,
            failQty: q.failQty > 0 ? q.failQty : undefined,
          })),
        }),
        field(labels.detailAcceptedQty, formatQty(first.acceptedQty, matUom)),
        field(labels.detailFailQty, formatQty(totalFail, matUom)),
        field(labels.detailHandler, first.handledBy),
        field(labels.detailTime, first.created),
        fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join("；")),
        ...pickOrderDetailFields(picks, labels.pickOrder),
      ),
    });
  });

  let lastMaterialPutawayWh = "";
  (m.putawayEvents ?? []).forEach((p) => {
    const key = `${p.inventoryLotLineId}-${p.timestamp}`;
    if (ctx.seenPutawayKeys.has(key)) return;
    if (isTransferInboundPutaway(p.refType)) return;
    ctx.seenPutawayKeys.add(key);
    const linkKind = docLinkFromRefType(p.refType);
    const pres = resolvePutawayPresentation(
      labels.tr,
      {
        nodePutaway: labels.nodePutaway,
        nodePutawayTransfer: labels.nodePutawayTransfer,
        putawayTransferDetail: labels.putawayTransferDetail,
      },
      p.status,
      p.refType,
    );
    const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · ");
    if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim();
    nodes.push({
      id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`,
      kind: "PUTAWAY",
      timestamp: p.timestamp,
      sortKey: parseSortKey(p.timestamp, nextSeq(ctx)),
      title: pres.title,
      subtitle: appendPickToSubtitle(
        [pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") ||
          m.materialItemCode,
        picks,
        labels.pickOrder,
      ),
      qty: p.qty,
      uom: matUom,
      meta: meta || undefined,
      refType: p.refType,
      refCode: p.refCode,
      refId: p.refId,
      docLinkKind: linkKind,
      putawayStatusLabel: pres.chipLabel,
      putawayStatusDetail: pres.statusDetail,
      warehouseCode: p.warehouseCode,
      traceLotNo: m.materialLotNo,
      traceItemCode: m.materialItemCode,
      categoryLabel: labels.categoryPutaway,
      details: [
        field(labels.detailStatus, pres.statusDetail),
        field(labels.detailItemCode, m.materialItemCode),
        field(labels.detailLot, m.materialLotNo),
        field(
          isTransferInboundPutaway(p.refType)
            ? labels.detailInboundSiNo
            : (p.refType ?? "").trim().toUpperCase() === "PO"
              ? labels.detailPurchaseOrderNo
              : (p.refType ?? "").trim().toUpperCase() === "JO"
                ? labels.jobOrder
                : labels.detailSourceDoc,
          p.refCode,
          {
          linkKind,
          linkCode: p.refCode,
          linkId: p.refId,
        }),
        field(labels.detailPutawayBin, p.warehouseCode),
        field(labels.detailQty, formatQty(p.qty, matUom)),
        field(labels.detailHandler, p.handledBy),
        field(labels.detailTime, p.timestamp),
        ...pickOrderDetailFields(picks, labels.pickOrder),
      ],
    });
  });

  // ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」.
  if (origin && lotId != null) {
    const originType = origin.type.trim().toUpperCase();
    if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) {
      ctx.seenInboundLots.add(lotId);
      const adjWh = lastMaterialPutawayWh;
      const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · ");
      // Place at/after related putaway so chronological X and 上架 → 調整 edges match FG.
      const lastPutawayTs = (m.putawayEvents ?? [])
        .map((p) => p.timestamp)
        .filter((t): t is string => Boolean(t?.trim()))
        .sort()
        .at(-1);
      const adjTimestamp =
        lastPutawayTs &&
        parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0)
          ? lastPutawayTs
          : origin.receiptDate;
      // Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整
      // (same calendar time previously ordered adj left of putaway → back-arrow).
      const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null;
      const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx));
      nodes.push({
        id: `mat-adj-${lotId}-${origin.stockInLineId}`,
        kind: "ADJUSTMENT",
        timestamp: origin.receiptDate,
        sortKey:
          putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase,
        title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`,
        subtitle: appendPickToSubtitle(
          [labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo]
            .filter(Boolean)
            .join(" · ") || m.materialItemCode,
          picks,
          labels.pickOrder,
        ),
        qty: origin.acceptedQty,
        uom: matUom,
        adjustmentDirection: "IN",
        meta: meta || undefined,
        refType: origin.type,
        refCode: origin.refCode,
        refId: origin.refId,
        warehouseCode: adjWh || undefined,
        traceLotNo: m.materialLotNo,
        traceItemCode: m.materialItemCode,
        categoryLabel: labels.categoryAdjustment,
        details: detailsOf(
          field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)),
          field(labels.detailAdjustmentRef, origin.refCode),
          field(labels.detailItemCode, m.materialItemCode),
          field(labels.detailLot, m.materialLotNo),
          ...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []),
          field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
          field(labels.detailTime, origin.receiptDate),
          ...pickOrderDetailFields(picks, labels.pickOrder),
        ),
      });
    }
  }

  return nodes;
};

const buildNestedJoCreatedNode = (
  m: ItemLotTraceMaterialInput,
  labels: JoPreludeGraphLabels,
  picks: LotPickRef[],
  ctx: PreludeBuildContext,
): TraceGraphNode | null => {
  const nested = m.nestedJoPrelude;
  const jo = nested?.jobOrder;
  if (!jo?.jobOrderCode?.trim()) return null;

  const nestedInputs = nested?.materialInputs ?? [];
  const nestedPickTs = nestedInputs
    .map((input) => input.pickedAt)
    .find((ts) => ts?.trim());
  const nestedProdTs = (m.productionSteps ?? [])
    .map((step) => step.startTime)
    .find((ts) => ts?.trim());
  const origin = m.stockInOrigin;
  const ts =
    jo.createdAt?.trim() ||
    jo.planStart?.trim() ||
    nestedPickTs?.trim() ||
    nestedProdTs?.trim() ||
    origin?.receiptDate?.trim() ||
    null;
  const matUom = m.materialUom?.trim() || "";
  const qty = origin?.acceptedQty ?? jo.reqQty;

  return {
    id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`,
    kind: "JO_CREATED",
    timestamp: ts,
    sortKey: parseSortKey(ts, nextSeq(ctx)),
    title: labels.nodeJoCreated,
    subtitle: appendPickToSubtitle(
      [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode,
      picks,
      labels.pickOrder,
    ),
    qty,
    uom: matUom,
    refType: "JO",
    refCode: jo.jobOrderCode,
    refId: jo.jobOrderId,
    docLinkKind: "jo",
    traceLotNo: m.materialLotNo,
    traceItemCode: m.materialItemCode,
    categoryLabel: labels.categoryProduction,
    details: [
      field(labels.detailType, labels.nodeJoCreated),
      field(labels.jobOrder, jo.jobOrderCode, {
        linkKind: "jo",
        linkCode: jo.jobOrderCode,
        linkId: jo.jobOrderId,
      }),
      field(labels.detailItemCode, m.materialItemCode),
      field(labels.detailLot, m.materialLotNo),
      field(labels.detailQty, formatQty(qty, matUom)),
      field(labels.detailTime, ts),
      ...pickOrderDetailFields(picks, labels.pickOrder),
    ],
  };
};

const buildMaterialInputChainNodes = (
  materialInputs: ItemLotTraceMaterialInput[],
  labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
  lotPicks: Map<string, LotPickRef[]>,
  ctx: PreludeBuildContext,
): TraceGraphNode[] => {
  const nodes: TraceGraphNode[] = [];

  materialInputs.forEach((m) => {
    const joProduced = isJoProducedMaterial(m);
    const nestedInputs = m.nestedJoPrelude?.materialInputs ?? [];
    const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo);

    if (joProduced && m.nestedJoPrelude) {
      const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx);
      if (nestedJoCreated) nodes.push(nestedJoCreated);
    }

    if (joProduced && nestedInputs.length > 0) {
      const nestedLotPicks = buildLotPickOrderMap(nestedInputs);
      nodes.push(
        ...buildMaterialInputChainNodes(
          nestedInputs,
          labels,
          nestedLotPicks,
          ctx,
        ),
      );
      nestedInputs.forEach((nm, ni) => {
        nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo));
      });
    }

    if (joProduced && (m.productionSteps?.length ?? 0) > 0) {
      nodes.push(...buildMaterialProductionNodes(m, labels, ctx));
    }

    nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx));
  });

  return nodes;
};

export const buildJoPreludeGraphNodes = (
  joPrelude: ItemLotTraceJoPrelude,
  labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
): TraceGraphNode[] => {
  const ctx: PreludeBuildContext = {
    seenPurchaseLines: new Set(),
    seenInboundLots: new Set(),
    seenReceiptLots: new Set(),
    seenQcKeys: new Set(),
    seenPutawayKeys: new Set(),
    pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude),
    seq: 0,
  };
  const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs);

  const nodes: TraceGraphNode[] = [
    ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx),
    ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))),
  ];

  return nodes.sort((a, b) => {
    if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
    return a.id.localeCompare(b.id);
  });
};
