import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
import { isDoGroupChild } from "./traceDoGroupLayout";
import { isTransferInboundPutaway, warehouseCodesMatch } from "./tracePutawayUtils";
import type { TraceGraphLayoutNode, TraceGraphPhase } from "./traceGraphLayout";

export const sortNodesInPhase = (a: TraceGraphLayoutNode, b: TraceGraphLayoutNode): number => {
  if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
  return a.sequenceIndex - b.sequenceIndex;
};

const earliestNodeInPhase = (
  nodes: TraceGraphLayoutNode[],
  phase: TraceGraphPhase,
  dayKey?: string,
): TraceGraphLayoutNode | null => {
  let best: TraceGraphLayoutNode | null = null;
  nodes.forEach((n) => {
    if (n.phase !== phase) return;
    if (dayKey != null && n.dayKey !== dayKey) return;
    if (!best || n.sortKey < best.sortKey) best = n;
  });
  return best;
};

const earliestSortKey = (
  nodes: TraceGraphLayoutNode[],
  phase: TraceGraphPhase,
  dayKey?: string,
): number => earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?? Number.MAX_SAFE_INTEGER;

/** Sort phases by earliest event time; tie-break with canonical phase order. */
const sortPhasesByTimestamp = (
  nodes: TraceGraphLayoutNode[],
  phases: TraceGraphPhase[],
  phaseOrder: TraceGraphPhase[],
  dayKey?: string,
): TraceGraphPhase[] =>
  [...phases].sort((a, b) => {
    const ta = earliestSortKey(nodes, a, dayKey);
    const tb = earliestSortKey(nodes, b, dayKey);
    if (ta !== tb) return ta - tb;
    return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
  });

export const sortPhasesByEarliestEvent = (
  nodes: TraceGraphLayoutNode[],
  phases: TraceGraphPhase[],
  phaseOrder: TraceGraphPhase[],
  dayKey?: string,
): TraceGraphPhase[] => sortPhasesByTimestamp(nodes, phases, phaseOrder, dayKey);

/** FG cross-day flow: chronological across days; same calendar day keeps canonical phase order for edges. */
const sortFgPhasesForCrossDayFlow = (
  nodes: TraceGraphLayoutNode[],
  phases: TraceGraphPhase[],
  phaseOrder: TraceGraphPhase[],
): TraceGraphPhase[] =>
  [...phases].sort((a, b) => {
    const nodeA = earliestNodeInPhase(nodes, a);
    const nodeB = earliestNodeInPhase(nodes, b);
    const ta = nodeA?.sortKey ?? Number.MAX_SAFE_INTEGER;
    const tb = nodeB?.sortKey ?? Number.MAX_SAFE_INTEGER;
    const sameDay =
      nodeA != null &&
      nodeB != null &&
      nodeA.dayKey !== "—" &&
      nodeA.dayKey === nodeB.dayKey;
    if (sameDay) {
      return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
    }
    if (ta !== tb) return ta - tb;
    return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
  });

export const phaseFromKind = (
  kind: TraceGraphNodeKind,
  phaseOrder: TraceGraphPhase[],
  traceLotNo?: string,
  refType?: string | null,
): TraceGraphPhase => {
  const materialPrelude = Boolean(traceLotNo?.trim());
  switch (kind) {
    case "MATERIAL_IN":
      return "INBOUND";
    case "JO_CREATED":
      // Sit with 工單提料 so 建立工單 → 工單提料 group stay in one lane.
      if (phaseOrder.includes("MATERIAL_PICK")) return "MATERIAL_PICK";
      return "INBOUND";
    case "MATERIAL_QC":
    case "QC":
    case "FAIL":
      return "QC";
    case "MATERIAL_PICK":
      return "MATERIAL_PICK";
    case "PICK_GROUP":
      return "MATERIAL_PICK";
    case "PRODUCTION_STEP":
    case "BYPRODUCT":
    case "SCRAP":
    case "DEFECT":
      return "PRODUCTION";
    case "OPEN":
      return "INBOUND";
    case "DO_OUT":
    case "REPLENISHMENT_CREATED":
    case "JO_OUT":
    case "PO_OUT":
    case "DO_GROUP":
    case "RETURN":
      return "OUTBOUND";
    case "REPACK":
      return "WAREHOUSE";
    case "PURCHASE":
      return "PURCHASE";
    case "RECEIPT":
    case "IN":
      return "INBOUND";
    case "PUTAWAY":
      if (isTransferInboundPutaway(refType)) return "WAREHOUSE";
      return "PUTAWAY";
    case "TRANSFER":
      return "WAREHOUSE";
    case "ADJUSTMENT":
      // Material BOM ADJ stock-in lives next to material putaway (上架 → 庫存調整).
      return materialPrelude ? "PUTAWAY" : "WAREHOUSE";
    case "OUT":
      return "OUTBOUND";
    case "STOCK_TAKE":
      return "STOCK_TAKE";
    case "EXPIRED":
    case "DEPLETED":
      return "OUTBOUND";
    default:
      return phaseOrder.includes("WAREHOUSE") ? "WAREHOUSE" : phaseOrder[0];
  }
};

export const materialInputsHaveProduction = (inputs: ItemLotTraceMaterialInput[]): boolean =>
  inputs.some(
    (m) =>
      (m.productionSteps?.length ?? 0) > 0 ||
      (m.nestedJoPrelude?.materialInputs.length
        ? materialInputsHaveProduction(m.nestedJoPrelude.materialInputs)
        : false),
  );

const NO_FLOW_EDGE_SOURCE_KINDS = new Set<TraceGraphNodeKind>(["EXPIRED", "DEPLETED"]);

const NO_FLOW_EDGE_KINDS = new Set<TraceGraphNodeKind>([
  "REPACK",
  "BYPRODUCT",
]);

const TERMINAL_TARGET_KINDS = new Set<TraceGraphNodeKind>(["EXPIRED", "DEPLETED"]);

const TERMINAL_PREDECESSOR_KINDS = new Set<TraceGraphNodeKind>([
  "PUTAWAY",
  "TRANSFER",
  "ADJUSTMENT",
  "STOCK_TAKE",
  "REPACK",
  "DO_OUT",
  "DO_GROUP",
  "JO_OUT",
  "PO_OUT",
  "OUT",
  "RETURN",
]);

const nodeScopePrefix = (node: TraceGraphLayoutNode): string => {
  const match = /^loc-(\d+)-/.exec(node.id);
  return match ? `loc-${match[1]}-` : "";
};

const buildTerminalStateEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
) => {
  const terminals = nodes.filter((n) => TERMINAL_TARGET_KINDS.has(n.kind));
  terminals.forEach((terminal) => {
    const scope = nodeScopePrefix(terminal);
    const scoped = nodes.filter((n) => nodeScopePrefix(n) === scope);
    const predecessors = scoped
      .filter((n) => TERMINAL_PREDECESSOR_KINDS.has(n.kind) && n.id !== terminal.id)
      .sort(sortNodesInPhase);
    const from = predecessors[predecessors.length - 1];
    if (from) add(from, terminal);
  });
};

const WAREHOUSE_FLOW_KINDS = new Set<TraceGraphNodeKind>(["TRANSFER", "ADJUSTMENT"]);

/** Predecessors that represent on-hand inventory state (skip QC / production). */
const INVENTORY_STATE_KINDS = new Set<TraceGraphNodeKind>([
  "PUTAWAY",
  "TRANSFER",
  "ADJUSTMENT",
  "OUT",
  "DO_OUT",
  "JO_OUT",
  "PO_OUT",
  "DO_GROUP",
  "STOCK_TAKE",
  "RETURN",
  "RECEIPT",
  "IN",
  "OPEN",
]);

export const isMaterialPreludeNode = (n: TraceGraphLayoutNode): boolean =>
  (["MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK"] as TraceGraphNodeKind[]).includes(n.kind) ||
  (n.kind === "JO_CREATED" && Boolean(n.traceLotNo?.trim())) ||
  (n.kind === "PUTAWAY" && Boolean(n.traceLotNo?.trim())) ||
  (n.kind === "ADJUSTMENT" && Boolean(n.traceLotNo?.trim())) ||
  (n.kind === "PRODUCTION_STEP" && Boolean(n.traceLotNo?.trim())) ||
  ((n.kind === "SCRAP" || n.kind === "DEFECT") && Boolean(n.traceLotNo?.trim())) ||
  ((n.kind === "PURCHASE" || n.kind === "RECEIPT") && Boolean(n.traceLotNo?.trim()));

const materialLotKey = (n: TraceGraphLayoutNode): string | null => {
  const lot = n.traceLotNo?.trim();
  if (!lot) return null;
  const item = n.traceItemCode?.trim() ?? "";
  return `${item}::${lot}`;
};

const resolvePickFlowTarget = (
  pick: TraceGraphLayoutNode,
  nodes: TraceGraphLayoutNode[],
): TraceGraphLayoutNode => {
  const groupId = pick.doGroupId?.trim();
  if (!groupId) return pick;
  return nodes.find((n) => n.id === groupId) ?? pick;
};

const PICK_OR_OUTBOUND_TARGET_KINDS = new Set<TraceGraphNodeKind>([
  "MATERIAL_PICK",
  "PICK_GROUP",
  "DO_OUT",
  "DO_GROUP",
  "JO_OUT",
  "PO_OUT",
  "OUT",
]);

export const shouldSkipTraceFlowEdge = (
  from: TraceGraphLayoutNode,
  to: TraceGraphLayoutNode,
): boolean => {
  if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) {
    if (to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP") return false;
    return true;
  }
  // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps
  // except material picks (handled by dedicated pick→step edges).
  if (
    to.kind === "PRODUCTION_STEP" &&
    from.kind !== "MATERIAL_PICK" &&
    from.kind !== "PICK_GROUP" &&
    from.kind !== "PRODUCTION_STEP"
  ) {
    return true;
  }
  if (
    (from.kind === "QC" || from.kind === "FAIL" || from.kind === "MATERIAL_QC") &&
    to.kind === "PRODUCTION_STEP"
  ) {
    return true;
  }
  if (from.kind === "PRODUCTION_STEP" && to.kind === "JO_CREATED") {
    return true;
  }
  if (from.kind === "PUTAWAY" && (to.kind === "QC" || to.kind === "FAIL")) {
    return true;
  }
  if (from.kind === "PRODUCTION_STEP" && PICK_OR_OUTBOUND_TARGET_KINDS.has(to.kind)) {
    return true;
  }
  if (to.kind === "REPLENISHMENT_CREATED") {
    // Inventory / warehouse → 建立補貨; block generic phase noise.
    return !(
      from.kind === "PUTAWAY" ||
      from.kind === "TRANSFER" ||
      from.kind === "ADJUSTMENT" ||
      from.kind === "OPEN" ||
      from.kind === "RECEIPT" ||
      from.kind === "IN"
    );
  }
  if (from.kind === "REPLENISHMENT_CREATED") {
    if (to.kind === "DO_GROUP") return false;
    if (to.kind !== "DO_OUT") return true;
    const targetSolId = from.replenishmentStockOutLineId;
    if (targetSolId != null && doOutNodeMatchesStockOutLineId(to.id, targetSolId)) {
      return false;
    }
    const doCode = from.refCode?.trim();
    if (doCode && doOutMatchesDeliveryCode(to, doCode)) return false;
    return true;
  }
  return false;
};

const doOutNodeMatchesStockOutLineId = (nodeId: string, stockOutLineId: number): boolean =>
  nodeId === `do-${stockOutLineId}` || nodeId.endsWith(`-${stockOutLineId}`);

const doOutMatchesDeliveryCode = (doOut: TraceGraphLayoutNode, doCode: string): boolean => {
  const code = doCode.trim();
  if (!code) return false;
  if (doOut.refCode?.trim() === code) return true;
  if (doOut.title?.includes(code)) return true;
  if (doOut.subtitle?.includes(code)) return true;
  return (doOut.details ?? []).some((d) => d.value?.trim() === code);
};

const shouldSkipGenericPhaseEdge = shouldSkipTraceFlowEdge;

const productionScopeKey = (n: TraceGraphLayoutNode): string =>
  n.traceLotNo?.trim() || "__fg__";

const normWh = (w?: string | null): string => (w ?? "").trim().toUpperCase();

const normRefType = (refType?: string | null): string => (refType ?? "").trim().toUpperCase();

const pickSourcePutawayForTransfer = (
  putaways: TraceGraphLayoutNode[],
  transfer: TraceGraphLayoutNode,
): TraceGraphLayoutNode | null => {
  const fromWh = normWh(transfer.transferFromWarehouse);
  const candidates = putaways.filter((p) => p.sortKey <= transfer.sortKey);
  const whMatched =
    fromWh.length > 0
      ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, fromWh))
      : candidates;
  if (!whMatched.length) return null;
  const nonTransfer = whMatched.filter((p) => normRefType(p.refType) !== "TRANSFER");
  const pool = nonTransfer.length > 0 ? nonTransfer : whMatched;
  return [...pool].sort(sortNodesInPhase).at(-1) ?? null;
};

const pickDestPutawayForTransfer = (
  putaways: TraceGraphLayoutNode[],
  transfer: TraceGraphLayoutNode,
): TraceGraphLayoutNode | null => {
  const toWh = normWh(transfer.transferToWarehouse);
  const candidates = putaways.filter((p) => p.sortKey >= transfer.sortKey);
  const whMatched =
    toWh.length > 0
      ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, toWh))
      : candidates;
  if (!whMatched.length) return null;
  const transferPutaways = whMatched.filter((p) => normRefType(p.refType) === "TRANSFER");
  const pool = transferPutaways.length > 0 ? transferPutaways : whMatched;
  return [...pool].sort(sortNodesInPhase)[0] ?? null;
};

const connectPutawayToWarehousePhase = (
  fromList: TraceGraphLayoutNode[],
  toList: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const transfers = toList.filter((n) => n.kind === "TRANSFER");
  transfers.forEach((tr) => {
    const src = pickSourcePutawayForTransfer(fromList, tr);
    if (src) add(src, tr);
  });

  const transferInbounds = toList.filter(
    (n) => n.kind === "PUTAWAY" && normRefType(n.refType) === "TRANSFER",
  );
  transferInbounds.forEach((dest) => {
    const src = pickSourcePutawayForTransfer(fromList, dest);
    if (src) add(src, dest);
  });

  if (transfers.length > 0 || transferInbounds.length > 0) {
    const linked = new Set<string>([
      ...transfers.map((n) => n.id),
      ...transferInbounds.map((n) => n.id),
    ]);
    const remaining = toList.filter((n) => !linked.has(n.id));
    remaining.forEach((to) => {
      // ADJUSTMENT / other warehouse events must link from same warehouse only.
      const from = pickUpstreamForTarget(fromList, to);
      if (from) add(from, to);
    });
    return;
  }

  toList.forEach((to) => {
    const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
    add(from, to);
  });
};

const buildTransferPutawayEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const putaways = nodes.filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n));
  const transfers = nodes.filter((n) => n.kind === "TRANSFER");
  transfers.forEach((tr) => {
    const src = pickSourcePutawayForTransfer(putaways, tr);
    if (src) add(src, tr);
    const dest = pickDestPutawayForTransfer(putaways, tr);
    if (dest) add(tr, dest);
  });

  // Every 轉倉入庫 card needs an inbound edge (not only the first WAREHOUSE node).
  const destInbound = putaways.filter((p) => normRefType(p.refType) === "TRANSFER");
  destInbound.forEach((dest) => {
    const src = pickSourcePutawayForTransfer(putaways, dest);
    if (src && src.id !== dest.id) add(src, dest);
  });
};

const PRELUDE_MATERIAL_PHASES: TraceGraphPhase[] = [
  "PURCHASE",
  "INBOUND",
  "PRODUCTION",
  "QC",
  "PUTAWAY",
  "MATERIAL_PICK",
];

const buildMaterialLotFlowEdgePairs = (
  nodes: TraceGraphLayoutNode[],
  phaseOrder: TraceGraphPhase[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  if (!phaseOrder.includes("MATERIAL_PICK")) return;

  const preludePhases = PRELUDE_MATERIAL_PHASES.filter((p) => phaseOrder.includes(p));
  const byLot = new Map<string, TraceGraphLayoutNode[]>();

  nodes.forEach((n) => {
    if (!isMaterialPreludeNode(n)) return;
    const key = materialLotKey(n);
    if (!key) return;
    const list = byLot.get(key) ?? [];
    list.push(n);
    byLot.set(key, list);
  });

  const nodesInPhase = (lotNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
    lotNodes
      .filter((n) => n.phase === phase && !NO_FLOW_EDGE_KINDS.has(n.kind))
      .sort(sortNodesInPhase);

  byLot.forEach((lotNodes) => {
    const phasesPresent = sortPhasesByEarliestEvent(
      lotNodes,
      preludePhases.filter((p) => nodesInPhase(lotNodes, p).length > 0),
      phaseOrder,
    );

    for (let i = 0; i < phasesPresent.length - 1; i++) {
      const fromPhase = phasesPresent[i];
      const toPhase = phasesPresent[i + 1];
      const fromList = nodesInPhase(lotNodes, fromPhase);
      const toList = nodesInPhase(lotNodes, toPhase);
      if (!fromList.length || !toList.length) continue;

      if (toPhase === "MATERIAL_PICK") {
        const upstream = fromList[fromList.length - 1]!;
        const seenPickTargets = new Set<string>();
        toList.forEach((pick) => {
          const target = resolvePickFlowTarget(pick, nodes);
          if (seenPickTargets.has(target.id)) return;
          seenPickTargets.add(target.id);
          add(upstream, target);
        });
      } else {
        add(fromList[fromList.length - 1]!, toList[0]!);
      }
    }

    // Same PUTAWAY lane: material 上架 → 庫存調整 (FG warehouse pattern).
    const putaways = lotNodes
      .filter((n) => n.kind === "PUTAWAY" && !NO_FLOW_EDGE_KINDS.has(n.kind))
      .sort(sortNodesInPhase);
    const adjustments = lotNodes
      .filter((n) => n.kind === "ADJUSTMENT" && !NO_FLOW_EDGE_KINDS.has(n.kind))
      .sort(sortNodesInPhase);
    if (putaways.length && adjustments.length) {
      adjustments.forEach((adj) => {
        const targetWh = adj.warehouseCode?.trim();
        const matched = targetWh
          ? putaways.filter((p) => warehouseCodesMatch(p.warehouseCode, targetWh))
          : putaways;
        const from = (matched.length ? matched : putaways).at(-1);
        if (from) add(from, adj);
      });
    }
  });
};

const buildPutawayToOutboundEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const inventorySources = nodes
    .filter(
      (n) =>
        !isMaterialPreludeNode(n) &&
        (n.kind === "PUTAWAY" ||
          n.kind === "TRANSFER" ||
          n.kind === "ADJUSTMENT" ||
          n.kind === "OPEN" ||
          n.kind === "RECEIPT" ||
          n.kind === "IN"),
    )
    .sort(sortNodesInPhase);
  if (!inventorySources.length) return;

  const outboundTargets = nodes.filter(
    (n) =>
      !isDoGroupChild(n) &&
      (n.kind === "DO_OUT" ||
        n.kind === "DO_GROUP" ||
        n.kind === "JO_OUT" ||
        n.kind === "PO_OUT" ||
        n.kind === "OUT" ||
        n.kind === "REPLENISHMENT_CREATED"),
  );
  if (!outboundTargets.length) return;

  outboundTargets.forEach((target) => {
    const from = pickUpstreamForTarget(inventorySources, target);
    if (from && !shouldSkipGenericPhaseEdge(from, target)) add(from, target);
  });
};

const buildPutawayToStockTakeEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const putaways = nodes
    .filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n))
    .sort(sortNodesInPhase);
  if (!putaways.length) return;

  nodes
    .filter((n) => n.kind === "STOCK_TAKE" && !isDoGroupChild(n))
    .forEach((stockTake) => {
      const scope = nodeScopePrefix(stockTake);
      // Stock take must stay within the same inventory-lot scope (loc-* / primary).
      const scopedPutaways = putaways.filter((n) => nodeScopePrefix(n) === scope);
      const from = pickUpstreamForTarget(scopedPutaways, stockTake);
      if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
    });
};

const inventoryWarehouseOf = (n: TraceGraphLayoutNode): string | undefined => {
  if (n.kind === "TRANSFER") {
    return n.transferToWarehouse?.trim() || n.warehouseCode?.trim();
  }
  return n.warehouseCode?.trim();
};

const pickUpstreamForTarget = (
  fromList: TraceGraphLayoutNode[],
  target: TraceGraphLayoutNode,
): TraceGraphLayoutNode | null => {
  if (!fromList.length) return null;

  const byTime = fromList.filter(
    (n) => n.column <= target.column || n.sortKey <= target.sortKey,
  );
  const pool = byTime.length > 0 ? byTime : fromList;

  const targetWh = target.warehouseCode?.trim();
  if (targetWh) {
    const matched = pool.filter((n) =>
      warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
    );
    if (matched.length) return matched[matched.length - 1]!;
    // No warehouse match in time window — do not link across warehouses.
    const anyMatched = fromList.filter((n) =>
      warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
    );
    return anyMatched.length ? anyMatched[anyMatched.length - 1]! : null;
  }

  const scope = nodeScopePrefix(target);
  const scoped = pool.filter((n) => nodeScopePrefix(n) === scope);
  if (scoped.length) return scoped[scoped.length - 1]!;

  const upstreamWhs = new Set(
    pool
      .map((n) => (inventoryWarehouseOf(n) ?? "").trim().toUpperCase())
      .filter(Boolean),
  );
  if (upstreamWhs.size <= 1) return pool[pool.length - 1]!;
  return null;
};

const buildFgLotFlowEdgePairs = (
  nodes: TraceGraphLayoutNode[],
  phaseOrder: TraceGraphPhase[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const fgPhases = phaseOrder.filter((p) => p !== "MATERIAL_PICK");
  const fgNodes = nodes.filter(
    (n) =>
      !isMaterialPreludeNode(n) &&
      !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
      !NO_FLOW_EDGE_KINDS.has(n.kind) &&
      !isDoGroupChild(n),
  );
  if (fgNodes.length < 2) return;

  const nodesInPhase = (phase: TraceGraphPhase) =>
    fgNodes.filter((n) => n.phase === phase).sort(sortNodesInPhase);

  const phasesPresent = sortFgPhasesForCrossDayFlow(
    fgNodes,
    fgPhases.filter((p) => nodesInPhase(p).length > 0),
    phaseOrder,
  );

  for (let i = 0; i < phasesPresent.length - 1; i++) {
    const fromPhase = phasesPresent[i];
    const toPhase = phasesPresent[i + 1];
    const fromList = nodesInPhase(fromPhase);
    const toList = nodesInPhase(toPhase);
    if (!fromList.length || !toList.length) continue;

    if (fromPhase === "OUTBOUND" && toPhase === "STOCK_TAKE") continue;

    if (
      fromPhase === "OUTBOUND" &&
      (toPhase === "WAREHOUSE" || toPhase === "PUTAWAY" || toPhase === "STOCK_TAKE")
    ) {
      continue;
    }

    if (fromPhase === "PUTAWAY" && toPhase === "WAREHOUSE") {
      connectPutawayToWarehousePhase(fromList, toList, add);
      continue;
    }

    if (toPhase === "OUTBOUND") {
      toList.forEach((out) => {
        const scope = nodeScopePrefix(out);
        const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
        const from = pickUpstreamForTarget(scopedFrom, out);
        if (from && !shouldSkipGenericPhaseEdge(from, out)) add(from, out);
      });
    } else if (toPhase === "STOCK_TAKE") {
      toList.forEach((stockTake) => {
        const scope = nodeScopePrefix(stockTake);
        const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
        const from = pickUpstreamForTarget(scopedFrom, stockTake);
        if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
      });
    } else if (toList.length > 1) {
      const targets =
        toPhase === "PUTAWAY"
          ? (() => {
              const nonTransfer = toList.filter((n) => normRefType(n.refType) !== "TRANSFER");
              return nonTransfer.length > 0 ? nonTransfer : toList;
            })()
          : toList;
      targets.forEach((to) => {
        const from = pickUpstreamForTarget(fromList, to);
        if (from && !shouldSkipGenericPhaseEdge(from, to)) add(from, to);
      });
    } else {
      const to = toList[0]!;
      const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
      if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
    }
  }
};

const sortProductionStepsForChain = (
  a: TraceGraphLayoutNode,
  b: TraceGraphLayoutNode,
): number => {
  const sa = a.bomProcessSeqNo;
  const sb = b.bomProcessSeqNo;
  if (sa != null && sb != null && sa !== sb) return sa - sb;
  if (sa != null && sb == null) return -1;
  if (sa == null && sb != null) return 1;
  return sortNodesInPhase(a, b);
};

const buildMaterialPickToProductionStepEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const prodSteps = nodes.filter(
    (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n),
  );
  if (!prodSteps.length) return;

  const productionByScopeAndProcess = new Map<string, TraceGraphLayoutNode[]>();
  prodSteps.forEach((n) => {
    if (n.bomProcessId == null) return;
    const key = `${productionScopeKey(n)}::${n.bomProcessId}`;
    const list = productionByScopeAndProcess.get(key) ?? [];
    list.push(n);
    productionByScopeAndProcess.set(key, list);
  });

  const resolveProductionTarget = (
    scope: string,
    pick: TraceGraphLayoutNode,
  ): TraceGraphLayoutNode | null => {
    const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope;

    if (pick.bomProcessId != null) {
      const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`);
      if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!;
    }

    if (pick.bomProcessSeqNo != null) {
      const bySeq = prodSteps.filter(
        (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo,
      );
      if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!;
    }

    const itemCode = pick.traceItemCode?.trim().toUpperCase();
    if (itemCode) {
      const byItem = prodSteps.filter(
        (n) =>
          inScope(n) &&
          (n.stepMaterialItemCodes ?? []).some(
            (code) => code.trim().toUpperCase() === itemCode,
          ),
      );
      if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!;
    }

    return null;
  };

  const connectPickOrGroup = (
    source: TraceGraphLayoutNode,
    pick: TraceGraphLayoutNode,
    seenTargets: Set<string>,
  ) => {
    const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__";
    const target = resolveProductionTarget(scope, pick);
    if (!target || seenTargets.has(target.id)) return;
    seenTargets.add(target.id);
    add(
      {
        ...source,
        feedsProductionScopeLotNo: pick.feedsProductionScopeLotNo ?? source.feedsProductionScopeLotNo,
      },
      target,
    );
  };

  nodes
    .filter((n) => n.kind === "PICK_GROUP")
    .forEach((group) => {
      const children = nodes.filter(
        (n) => n.doGroupId === group.id && n.kind === "MATERIAL_PICK",
      );
      const seenTargets = new Set<string>();
      children.forEach((child) => connectPickOrGroup(group, child, seenTargets));
    });

  nodes
    .filter((n) => n.kind === "MATERIAL_PICK" && !n.doGroupId)
    .forEach((pick) => connectPickOrGroup(pick, pick, new Set()));
};

const buildProductionToFgQcEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const prodSteps = nodes.filter(
    (n) => n.kind === "PRODUCTION_STEP" && !isMaterialPreludeNode(n) && !isDoGroupChild(n),
  );
  const qcNodes = nodes.filter((n) => n.kind === "QC" && !isMaterialPreludeNode(n));
  if (!prodSteps.length || !qcNodes.length) return;

  const lastProd = [...prodSteps].sort(sortNodesInPhase).at(-1)!;
  const firstQc = [...qcNodes].sort(sortNodesInPhase)[0]!;
  if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc);
};

const buildProductionStepChainEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const byScope = new Map<string, TraceGraphLayoutNode[]>();
  nodes
    .filter((n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n))
    .forEach((n) => {
      const key = productionScopeKey(n);
      const list = byScope.get(key) ?? [];
      list.push(n);
      byScope.set(key, list);
    });

  byScope.forEach((list) => {
    const sorted = [...list].sort(sortProductionStepsForChain);
    for (let i = 0; i < sorted.length - 1; i++) {
      add(sorted[i]!, sorted[i + 1]!);
    }
  });
};

const joCodeForPickTarget = (
  target: TraceGraphLayoutNode,
  nodes: TraceGraphLayoutNode[],
): string | null => {
  // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*.
  const fromNode = (n: TraceGraphLayoutNode): string | null => {
    const jo = n.jobOrderCode?.trim();
    if (jo) return jo;
    const conso = n.consoCode?.trim();
    if (conso && /^JO[-_]/i.test(conso)) return conso;
    return null;
  };

  if (target.kind === "PICK_GROUP") {
    const direct = fromNode(target);
    if (direct) return direct;
    const child = nodes.find((n) => n.doGroupId === target.id && fromNode(n));
    return child ? fromNode(child) : null;
  }
  if (target.kind === "MATERIAL_PICK") {
    return fromNode(target);
  }
  return null;
};

const buildJoCreatedToMaterialPickEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED");
  if (!joCreatedNodes.length) return;

  const pickTargets = nodes.filter(
    (n) =>
      !isDoGroupChild(n) &&
      (n.kind === "PICK_GROUP" ||
        (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())),
  );
  if (!pickTargets.length) return;

  const feedLotForTarget = (target: TraceGraphLayoutNode): string => {
    if (target.kind === "PICK_GROUP") {
      return (
        nodes
          .find(
            (n) =>
              n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()),
          )
          ?.feedsProductionScopeLotNo?.trim() ?? ""
      );
    }
    return target.feedsProductionScopeLotNo?.trim() ?? "";
  };

  pickTargets.forEach((target) => {
    const pickJoCode = joCodeForPickTarget(target, nodes);
    const matches = joCreatedNodes.filter((jo) => {
      const joCode = jo.refCode?.trim();
      return Boolean(joCode && pickJoCode && joCode === pickJoCode);
    });
    if (!matches.length) return;

    const feedLot = feedLotForTarget(target);
    let pool: TraceGraphLayoutNode[];
    if (feedLot) {
      const scoped = matches.filter((jo) => jo.traceLotNo?.trim() === feedLot);
      pool = scoped.length ? scoped : matches;
    } else {
      // FG 工單提料: prefer the FG 建立工單 card (no material lot), not nested JO output.
      const fg = matches.filter((jo) => !jo.traceLotNo?.trim());
      pool = fg.length ? fg : matches;
    }
    const joCreated = [...pool].sort(sortNodesInPhase)[0]!;
    add(joCreated, target);
  });
};

const resolveDoOutFlowTarget = (
  doOut: TraceGraphLayoutNode,
  nodes: TraceGraphLayoutNode[],
): TraceGraphLayoutNode => {
  const groupId = doOut.doGroupId?.trim();
  if (!groupId) return doOut;
  return nodes.find((n) => n.id === groupId) ?? doOut;
};

const findDoOutForReplenishment = (
  replenishment: TraceGraphLayoutNode,
  nodes: TraceGraphLayoutNode[],
): TraceGraphLayoutNode | null => {
  const stockOutLineId = replenishment.replenishmentStockOutLineId;
  const doOuts = nodes.filter((n) => n.kind === "DO_OUT");
  if (stockOutLineId != null) {
    const bySol = doOuts.find((n) => doOutNodeMatchesStockOutLineId(n.id, stockOutLineId));
    if (bySol) return bySol;
  }
  const doCode = replenishment.refCode?.trim();
  if (!doCode) return null;
  const byCode = doOuts.filter((n) => doOutMatchesDeliveryCode(n, doCode));
  if (!byCode.length) return null;
  const replenishFlagged = byCode.filter((n) => n.doOutboundIsReplenish);
  const pool = replenishFlagged.length ? replenishFlagged : byCode;
  return [...pool].sort(sortNodesInPhase)[0] ?? null;
};

const buildReplenishmentToDoOutEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  nodes
    .filter((n) => n.kind === "REPLENISHMENT_CREATED")
    .forEach((replenishment) => {
      const doOut = findDoOutForReplenishment(replenishment, nodes);
      if (!doOut) return;
      add(replenishment, resolveDoOutFlowTarget(doOut, nodes));
    });
};

const connectWarehousePhaseChainEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const chainNodes = nodes
    .filter(
      (n) =>
        !isMaterialPreludeNode(n) &&
        !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
        !NO_FLOW_EDGE_KINDS.has(n.kind) &&
        !isDoGroupChild(n),
    )
    .sort(sortNodesInPhase);

  chainNodes
    .filter((n) => WAREHOUSE_FLOW_KINDS.has(n.kind))
    .forEach((warehouseNode) => {
      if (warehouseNode.kind === "TRANSFER") {
        const putaways = chainNodes.filter(
          (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n),
        );
        const src = pickSourcePutawayForTransfer(putaways, warehouseNode);
        if (src) {
          add(src, warehouseNode);
          return;
        }
      }

      const predecessors = chainNodes.filter((n) => n.sortKey < warehouseNode.sortKey);
      const targetWh = warehouseNode.warehouseCode?.trim();
      const sameWarehouse = (n: TraceGraphLayoutNode) =>
        !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);

      const warehousePredecessors = predecessors.filter(
        (n) => WAREHOUSE_FLOW_KINDS.has(n.kind) && sameWarehouse(n),
      );
      if (warehousePredecessors.length > 0) {
        add(warehousePredecessors[warehousePredecessors.length - 1]!, warehouseNode);
        return;
      }

      const inventoryPredecessors = predecessors.filter(
        (n) => INVENTORY_STATE_KINDS.has(n.kind) && sameWarehouse(n),
      );
      if (inventoryPredecessors.length > 0) {
        add(inventoryPredecessors[inventoryPredecessors.length - 1]!, warehouseNode);
        return;
      }

      // ADJUSTMENT with a known warehouse must not fall back to a different warehouse.
      if (warehouseNode.kind === "ADJUSTMENT" && targetWh) return;

      const lastBefore = predecessors[predecessors.length - 1];
      if (lastBefore) add(lastBefore, warehouseNode);
    });
};

const connectStockTakeInboundEdges = (
  nodes: TraceGraphLayoutNode[],
  add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
): void => {
  const chainNodes = nodes
    .filter(
      (n) =>
        !isMaterialPreludeNode(n) &&
        !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
        !NO_FLOW_EDGE_KINDS.has(n.kind) &&
        !isDoGroupChild(n),
    )
    .sort(sortNodesInPhase);

  const inventoryPredecessorKinds = new Set<TraceGraphNodeKind>([
    "PUTAWAY",
    "TRANSFER",
    "ADJUSTMENT",
    "REPACK",
    "RECEIPT",
    "IN",
    "OPEN",
  ]);

  chainNodes
    .filter((n) => n.kind === "STOCK_TAKE")
    .forEach((stockTake) => {
      const targetScope = nodeScopePrefix(stockTake);
      const targetWh = stockTake.warehouseCode?.trim();
      const targetLot = stockTake.traceLotNo?.trim();
      const sameLot = (n: TraceGraphLayoutNode) => {
        if (nodeScopePrefix(n) !== targetScope) return false;
        if (!targetLot) return true;
        const fromLot = n.traceLotNo?.trim();
        return !fromLot || fromLot === targetLot;
      };
      const sameWarehouse = (n: TraceGraphLayoutNode) =>
        !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);

      const predecessors = chainNodes.filter(
        (n) =>
          n.kind !== "STOCK_TAKE" &&
          n.sortKey < stockTake.sortKey &&
          sameLot(n),
      );
      if (!predecessors.length) return;

      const putawayPredecessors = predecessors.filter(
        (n) =>
          n.kind === "PUTAWAY" && !isMaterialPreludeNode(n) && sameWarehouse(n),
      );
      const inventoryPredecessors = predecessors.filter(
        (n) => inventoryPredecessorKinds.has(n.kind) && sameWarehouse(n),
      );
      const from =
        putawayPredecessors[putawayPredecessors.length - 1] ??
        inventoryPredecessors[inventoryPredecessors.length - 1] ??
        // Known warehouse: never fall back across warehouses / other lots.
        (targetWh ? undefined : predecessors[predecessors.length - 1]);
      if (from) add(from, stockTake);
    });
};

export const buildTraceFlowEdgePairs = (
  nodes: TraceGraphLayoutNode[],
  phaseOrder: TraceGraphPhase[],
): Array<{ fromId: string; toId: string }> => {
  const pairs: Array<{ fromId: string; toId: string }> = [];
  const seen = new Set<string>();

  const add = (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => {
    if (NO_FLOW_EDGE_SOURCE_KINDS.has(from.kind) || NO_FLOW_EDGE_KINDS.has(from.kind)) return;
    if (NO_FLOW_EDGE_KINDS.has(to.kind)) return;
    const key = `${from.id}->${to.id}`;
    if (seen.has(key)) return;
    seen.add(key);
    pairs.push({ fromId: from.id, toId: to.id });
  };

  const byDay = new Map<string, TraceGraphLayoutNode[]>();
  nodes.forEach((n) => {
    const list = byDay.get(n.dayKey) ?? [];
    list.push(n);
    byDay.set(n.dayKey, list);
  });

  const sortedDays = Array.from(byDay.keys()).sort((a, b) => {
    if (a === "—") return 1;
    if (b === "—") return -1;
    return a.localeCompare(b);
  });

  const nodesInPhase = (dayNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
    dayNodes
      .filter(
        (n) =>
          n.phase === phase &&
          !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
          !NO_FLOW_EDGE_KINDS.has(n.kind) &&
          !isDoGroupChild(n),
      )
      .sort(sortNodesInPhase);

  for (const dayKey of sortedDays) {
    const dayNodes = byDay.get(dayKey)!;
    const phasesPresent = sortPhasesByEarliestEvent(
      dayNodes,
      phaseOrder.filter((p) => nodesInPhase(dayNodes, p).length > 0),
      phaseOrder,
      dayKey,
    );

    for (let i = 0; i < phasesPresent.length - 1; i++) {
      const fromList = nodesInPhase(dayNodes, phasesPresent[i]);
      const toList = nodesInPhase(dayNodes, phasesPresent[i + 1]);
      if (!fromList.length || !toList.length) continue;
      const from = fromList[fromList.length - 1]!;
      const to = toList[0]!;
      if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue;

      if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") {
        continue;
      }

      if (phasesPresent[i] === "PUTAWAY" && phasesPresent[i + 1] === "WAREHOUSE") {
        connectPutawayToWarehousePhase(fromList, toList, add);
        continue;
      }

      if (phasesPresent[i + 1] === "OUTBOUND" || phasesPresent[i + 1] === "STOCK_TAKE") {
        toList.forEach((target) => {
          const scope = nodeScopePrefix(target);
          const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
          const matched = pickUpstreamForTarget(scopedFrom, target);
          if (matched && !shouldSkipGenericPhaseEdge(matched, target)) add(matched, target);
        });
        continue;
      }

      if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
    }
  }

  buildMaterialLotFlowEdgePairs(nodes, phaseOrder, add);
  buildProductionStepChainEdges(nodes, add);
  buildProductionToFgQcEdges(nodes, add);
  buildJoCreatedToMaterialPickEdges(nodes, add);
  buildReplenishmentToDoOutEdges(nodes, add);
  buildMaterialPickToProductionStepEdges(nodes, add);
  buildFgLotFlowEdgePairs(nodes, phaseOrder, add);
  buildPutawayToOutboundEdges(nodes, add);
  buildPutawayToStockTakeEdges(nodes, add);
  buildTransferPutawayEdges(nodes, add);
  connectWarehousePhaseChainEdges(nodes, add);
  connectStockTakeInboundEdges(nodes, add);
  buildTerminalStateEdges(nodes, add);

  return pairs;
};

export type TraceFlowEdgePair = { fromId: string; toId: string };

/** Build edges from backend traceGraph when present; merge with local semantics. */
export const resolveTraceFlowEdgePairs = (
  nodes: TraceGraphLayoutNode[],
  phaseOrder: TraceGraphPhase[],
  backendEdges?: Array<{ fromKey: string; toKey: string }> | null,
): TraceFlowEdgePair[] => {
  const local = buildTraceFlowEdgePairs(nodes, phaseOrder);
  if (!backendEdges?.length) return local;

  const nodeByKey = new Map(nodes.map((n) => [n.id, n]));
  const merged = new Map<string, TraceFlowEdgePair>();
  const addPair = (fromId: string, toId: string) => {
    const from = nodeByKey.get(fromId);
    const to = nodeByKey.get(toId);
    if (!from || !to) return;
    if (shouldSkipTraceFlowEdge(from, to)) return;
    merged.set(`${fromId}->${toId}`, { fromId, toId });
  };

  backendEdges.forEach((e) => addPair(e.fromKey, e.toKey));
  local.forEach((e) => addPair(e.fromId, e.toId));
  return Array.from(merged.values());
};
