/**
 * AMS (Automatic Material System) helper utilities for Bambu Lab printers.
 * These functions handle color normalization, slot labeling, and tray ID calculations
 * for AMS, AMS-HT, and external spool configurations.
 */
import { parseUTCDate } from './date';

/**
 * Normalize color format from various sources for CSS rendering.
 * API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
 * Result is "#RRGGBB" for opaque colors and "#RRGGBBAA" when alpha < FF —
 * CSS accepts both forms on `fill` / `backgroundColor`, and preserving alpha
 * lets transparent filaments render translucent instead of collapsing to
 * solid black (#1545). Comparison helpers use normalizeColorForCompare which
 * still strips alpha, so type/colour matching is unaffected.
 */
export function normalizeColor(color: string | null | undefined): string {
  if (!color) return '#808080';
  const clean = color.replace('#', '');
  if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
    return `#${clean.substring(0, 8)}`;
  }
  return `#${clean.substring(0, 6)}`;
}

/**
 * Normalize color for comparison (case-insensitive, strip hash and alpha).
 */
export function normalizeColorForCompare(color: string | undefined): string {
  if (!color) return '';
  return color.replace('#', '').toLowerCase().substring(0, 6);
}

/**
 * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
 * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
 * IDs starting at 128). `trayCount` is required because the type can't be
 * inferred from the id alone — regular AMS IDs 0-3 can collide with the
 * normalized HT range otherwise.
 */
export function getAmsLabel(amsId: number | string, trayCount: number): string {
  const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
  const safeId = isNaN(id) ? 0 : id;
  if (safeId === 255) return 'External';
  // A2L "AMS Lite": the backend normalises its physical unit id 16 to 6 at
  // ingest (see a2l-am-unit-16). No regular AMS uses id 6, so this is a safe,
  // self-scoping label for the Lite's 4-slot unit.
  if (safeId === 6) return 'AMS Lite';
  const isHt = trayCount === 1;
  const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
  const letter = String.fromCharCode(65 + normalizedId);
  return isHt ? `HT-${letter}` : `AMS-${letter}`;
}

/**
 * Filament type equivalence groups.
 * Types within the same group are interchangeable on the printer side
 * (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).
 */
const FILAMENT_TYPE_GROUPS: string[][] = [
  ['PA-CF', 'PA12-CF', 'PAHT-CF'],
];

const _equivalenceMap: Record<string, string> = {};
for (const group of FILAMENT_TYPE_GROUPS) {
  const canonical = group[0];
  for (const t of group) {
    _equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();
  }
}

/**
 * Get the canonical filament type for equivalence matching.
 * Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.
 */
export function canonicalFilamentType(type: string | undefined): string {
  if (!type) return '';
  const upper = type.toUpperCase();
  return _equivalenceMap[upper] ?? upper;
}

/**
 * Check if two filament types are compatible (same type or same equivalence group).
 */
export function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {
  return canonicalFilamentType(a) === canonicalFilamentType(b);
}

/**
 * Check if two colors are visually similar within a threshold.
 * Uses RGB component comparison with configurable tolerance.
 * @param color1 - First hex color
 * @param color2 - Second hex color
 * @param threshold - Maximum difference per RGB component (default: 40)
 */
export function colorsAreSimilar(
  color1: string | undefined,
  color2: string | undefined,
  threshold = 40
): boolean {
  const hex1 = normalizeColorForCompare(color1);
  const hex2 = normalizeColorForCompare(color2);
  if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;

  const r1 = parseInt(hex1.substring(0, 2), 16);
  const g1 = parseInt(hex1.substring(2, 4), 16);
  const b1 = parseInt(hex1.substring(4, 6), 16);
  const r2 = parseInt(hex2.substring(0, 2), 16);
  const g2 = parseInt(hex2.substring(2, 4), 16);
  const b2 = parseInt(hex2.substring(4, 6), 16);

  return (
    Math.abs(r1 - r2) <= threshold &&
    Math.abs(g1 - g2) <= threshold &&
    Math.abs(b1 - b2) <= threshold
  );
}

const D65_WHITE: readonly [number, number, number] = [0.95047, 1.0, 1.08883];
const LAB_DELTA = 6 / 29;

/**
 * Convert a hex colour to CIE L*a*b* under D65, or null if it is unusable.
 *
 * Alpha is dropped by `normalizeColorForCompare`, deliberately: the alpha a
 * slicer writes for a transparent filament is not a colour the user chose, and
 * counting it would stop a transparent filament matching itself.
 */
function hexToLab(color: string | undefined): [number, number, number] | null {
  const hex = normalizeColorForCompare(color);
  if (!hex || hex.length < 6) return null;

  const channels = [0, 2, 4].map((i) => parseInt(hex.substring(i, i + 2), 16) / 255);
  if (channels.some(Number.isNaN)) return null;

  // sRGB gamma -> linear light.
  const [r, g, b] = channels.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));

  const xyz: [number, number, number] = [
    0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
    0.2126729 * r + 0.7151522 * g + 0.072175 * b,
    0.0193339 * r + 0.119192 * g + 0.9503041 * b,
  ];

  const f = (t: number) =>
    t > LAB_DELTA ** 3 ? Math.cbrt(t) : t / (3 * LAB_DELTA * LAB_DELTA) + 4 / 29;
  const [fx, fy, fz] = xyz.map((v, i) => f(v / D65_WHITE[i]));

  return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}

/**
 * CIEDE2000 colour difference between two L*a*b* triples.
 *
 * Straight transcription of the CIE formulation with kL = kC = kH = 1, kept
 * structurally identical to `perceptual_color_distance` in
 * `backend/app/utils/color_utils.py` so the two can be read side by side. They
 * must agree: the dialog must not promise a spool the scheduler would not pick.
 */
function ciede2000(lab1: [number, number, number], lab2: [number, number, number]): number {
  const [l1, a1, b1] = lab1;
  const [l2, a2, b2] = lab2;
  const rad = (deg: number) => (deg * Math.PI) / 180;

  const c1 = Math.hypot(a1, b1);
  const c2 = Math.hypot(a2, b2);
  const cBar7 = ((c1 + c2) / 2) ** 7;
  const g = 0.5 * (1 - Math.sqrt(cBar7 / (cBar7 + 25 ** 7)));

  const a1p = (1 + g) * a1;
  const a2p = (1 + g) * a2;
  const c1p = Math.hypot(a1p, b1);
  const c2p = Math.hypot(a2p, b2);

  const hue = (ap: number, bp: number) => {
    if (ap === 0 && bp === 0) return 0;
    const deg = (Math.atan2(bp, ap) * 180) / Math.PI;
    return deg < 0 ? deg + 360 : deg;
  };
  const h1p = hue(a1p, b1);
  const h2p = hue(a2p, b2);

  const dlp = l2 - l1;
  const dcp = c2p - c1p;

  const chromaProduct = c1p * c2p;
  let dhp = 0;
  if (chromaProduct !== 0) {
    dhp = h2p - h1p;
    if (dhp > 180) dhp -= 360;
    else if (dhp < -180) dhp += 360;
  }
  const dhpBig = 2 * Math.sqrt(chromaProduct) * Math.sin(rad(dhp) / 2);

  const lBar = (l1 + l2) / 2;
  const cBar = (c1p + c2p) / 2;

  let hBar: number;
  if (chromaProduct === 0) hBar = h1p + h2p;
  else if (Math.abs(h1p - h2p) <= 180) hBar = (h1p + h2p) / 2;
  else if (h1p + h2p < 360) hBar = (h1p + h2p + 360) / 2;
  else hBar = (h1p + h2p - 360) / 2;

  const t =
    1 -
    0.17 * Math.cos(rad(hBar - 30)) +
    0.24 * Math.cos(rad(2 * hBar)) +
    0.32 * Math.cos(rad(3 * hBar + 6)) -
    0.2 * Math.cos(rad(4 * hBar - 63));

  const cBarP7 = cBar ** 7;
  const rc = 2 * Math.sqrt(cBarP7 / (cBarP7 + 25 ** 7));
  const sl = 1 + (0.015 * (lBar - 50) ** 2) / Math.sqrt(20 + (lBar - 50) ** 2);
  const sc = 1 + 0.045 * cBar;
  const sh = 1 + 0.015 * cBar * t;
  const rt = -Math.sin(rad(2 * (30 * Math.exp(-(((hBar - 275) / 25) ** 2))))) * rc;

  const dL = dlp / sl;
  const dC = dcp / sc;
  const dH = dhpBig / sh;
  return Math.sqrt(dL * dL + dC * dC + dH * dH + rt * dC * dH);
}

/**
 * Perceptual distance between two hex colours, or null if either is unusable.
 *
 * Used to rank the candidates `colorsAreSimilar` admits. Eligibility stays the
 * per-channel box that shipped; this only decides which of several eligible
 * spools is closest, so no spool becomes usable or unusable because of it.
 *
 * It ranks by how far apart the colours *look*, not how far apart their numbers
 * are. RGB distance overweights blue badly enough to invert the answer: against
 * a required `#1E4821` green, a purple `#38202F` is the nearer of two eligible
 * spools by RGB and four times the further once measured perceptually.
 *
 * The scale is CIEDE2000 delta-E, where ~1 is a just-noticeable difference —
 * far smaller numbers than the RGB distances this replaced, and not comparable
 * against an RGB threshold.
 */
export function colorDistance(
  color1: string | undefined,
  color2: string | undefined,
): number | null {
  const lab1 = hexToLab(color1);
  const lab2 = hexToLab(color2);
  if (!lab1 || !lab2) return null;
  return ciede2000(lab1, lab2);
}

/**
 * The closest colour match among `candidates`, or undefined if none is similar
 * enough to qualify.
 *
 * Callers pass candidates in the order they already established — slot order,
 * or the "prefer lowest remaining" sort. Ties keep the earliest of them, so
 * that order survives as the tie-break and Prefer Lowest still decides between
 * two equally close spools, which is the case it was actually for.
 *
 * This exists so the four matchers that pick a spool (`autoMatchFilament`,
 * `computeAmsMapping`, `computeMappingWithOverrides`, `computeMatchDetails`)
 * share one ranking rule instead of four copies of "first one within
 * tolerance", which made the winner depend on AMS slot order.
 */
export function findNearestSimilar<T>(
  candidates: T[],
  requiredColor: string | undefined,
  getColor: (candidate: T) => string | undefined,
): T | undefined {
  let best: T | undefined;
  let bestDistance = Infinity;

  for (const candidate of candidates) {
    const color = getColor(candidate);
    if (!colorsAreSimilar(color, requiredColor)) continue;
    const distance = colorDistance(color, requiredColor);
    if (distance === null) continue;
    // Strict <: an equally close candidate never displaces an earlier one.
    if (distance < bestDistance) {
      best = candidate;
      bestDistance = distance;
    }
  }

  return best;
}

/**
 * Format slot label for display in the UI.
 * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
 * @param trayId - Tray/slot ID within the AMS unit (0-3)
 * @param isHt - Whether this is an AMS-HT unit (single tray)
 * @param isExternal - Whether this is the external spool holder
 */
export function formatSlotLabel(
  amsId: number,
  trayId: number,
  isHt: boolean,
  isExternal: boolean
): string {
  if (isExternal) return 'Ext';
  // Convert AMS ID to letter (A, B, C, D)
  // AMS-HT uses IDs starting at 128
  const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
  if (isHt) return `HT-${letter}`;
  return `${letter}${trayId + 1}`;
}

/**
 * Calculate global tray ID for MQTT command.
 * Used in the ams_mapping array sent to the printer.
 * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
 * @param trayId - Tray/slot ID within the AMS unit
 * @param isExternal - Whether this is the external spool holder
 * @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
 */
export function getGlobalTrayId(
  amsId: number,
  trayId: number,
  isExternal: boolean
): number {
  if (isExternal) return 254 + trayId;
  // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
  if (amsId >= 128) return amsId;
  return amsId * 4 + trayId;
}

/**
 * Get fill bar color based on spool fill level.
 * Matches PrintersPage thresholds and Bambu Lab brand green.
 */
export function getFillBarColor(fillLevel: number): string {
  if (fillLevel > 50) return '#00ae42'; // Green - good
  if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
  return '#ef4444'; // Red - critical (< 15%)
}

/**
 * Calculate fill level from Spoolman weight data.
 * Used as the first source in the Spoolman → Inventory → AMS fill chain.
 */
export function getSpoolmanFillLevel(
  linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
): number | null {
  if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
      || linkedSpool.filament_weight <= 0) return null;
  return Math.min(100, Math.round(
    (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
  ));
}

function toFixedHex(value: number, width: number): string {
  const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
  return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
}

// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
function hashSerialToHex32(serial: string): string {
  const input = (serial || '').trim().toUpperCase();
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
}

/**
 * Generate a stable fallback spool tag for slots without RFID identifiers.
 * Returns a 16-char hex string derived from the printer serial + slot position.
 */
export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
  return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
}

/**
 * Get minimum datetime for scheduling (now + 1 minute).
 * Returns ISO string format for datetime-local input.
 */
export function getMinDateTime(): string {
  const now = new Date();
  now.setMinutes(now.getMinutes() + 1);
  return now.toISOString().slice(0, 16);
}

/**
 * Check if a scheduled time is a placeholder far-future date.
 * Placeholder dates (more than 6 months out) are treated as ASAP.
 */
export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
  if (!scheduledTime) return false;
  const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
  return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
}

/**
 * Banding tie-break for `preferLowestSortKey`, mirroring backend
 * `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
 * regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
 * in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
 * beat AMS slot 0 — backend clamps to 10_000.
 */
function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
  if (amsId == null || amsId < 0) return 10_000;
  if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
  return amsId * 4 + (trayId ?? 0);
}

/**
 * Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
 *
 * Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
 * client-side sort that PrintModal pre-computes lines up with the dispatch-time
 * sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
 * the user's tracked grams beat the printer's per-cent estimate; within each
 * tier the lowest value wins, with the slot-position tie-break above so the
 * order is deterministic across identical spools.
 *
 * `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
 * the user's spool assignments. Pass `undefined` to fall back to remain%-only
 * sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
 */
export function preferLowestSortKey(
  f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
  inventoryByTrayId: Map<number, number> | undefined,
): [number, number, number] {
  const slot = slotPriority(f.amsId, f.trayId);
  if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
    return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
  }
  const remain = f.remain ?? -1;
  return [1, remain >= 0 ? remain : 101, slot];
}

/** Tuple compare for `preferLowestSortKey` outputs. */
export function compareSortKeys(
  a: [number, number, number],
  b: [number, number, number],
): number {
  return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}

/**
 * Effective "Prefer lowest remaining filament" preference for a given printer,
 * gated on its AMS Filament Backup state (#1766).
 *
 * Without backup, the printer can't switch to a second spool when the picked
 * one runs out — so even with the user setting on, sorting toward the lowest
 * leaves the print at risk. Mirrors the backend gate in
 * `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
 * (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
 */
export function effectivePreferLowest(
  setting: boolean | undefined,
  amsFilamentBackup: boolean | null | undefined,
): boolean {
  if (!setting) return false;
  return amsFilamentBackup !== false;
}

/**
 * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
 * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
 */
export function autoMatchFilament(
  req: { type?: string; color?: string; nozzle_id?: number | null },
  loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
  usedTrayIds: Set<number>,
  preferLowest?: boolean,
  inventoryByTrayId?: Map<number, number>,
): typeof loadedFilaments[number] | undefined {
  let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);

  if (preferLowest) {
    nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
      compareSortKeys(
        preferLowestSortKey(a, inventoryByTrayId),
        preferLowestSortKey(b, inventoryByTrayId),
      ),
    );
  }

  const exactMatch = nozzleFilaments.find(
    (f) =>
      !usedTrayIds.has(f.globalTrayId) &&
      filamentTypesCompatible(f.type, req.type) &&
      normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  );
  const similarMatch = exactMatch
    ? undefined
    : findNearestSimilar(
        nozzleFilaments.filter(
          (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type),
        ),
        req.color,
        (f) => f.color,
      );
  const typeOnlyMatch =
    exactMatch || similarMatch
      ? undefined
      : nozzleFilaments.find(
          (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)
        );
  return exactMatch ?? similarMatch ?? typeOnlyMatch;
}

/**
 * Filter loaded filaments to those valid for a given nozzle requirement.
 * For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
 */
export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
  loadedFilaments: T[],
  nozzleId: number | undefined | null,
): T[] {
  return loadedFilaments.filter(
    (f) => nozzleId == null || f.extruderId === nozzleId
  );
}

/**
 * List the distinct nozzle diameters the printer actually reports (#2618).
 * Mirrors the backend `_installed_nozzle_diameters`: reads each
 * `status.nozzles[].nozzle_diameter`, skips the empty-string / non-positive
 * defaults that populate a NozzleInfo before MQTT fills it in, and dedupes.
 *
 * Returns e.g. `['0.4']` (single-nozzle) or `['0.4', '0.6']` (dual-nozzle). An
 * empty array means "the printer hasn't told us its nozzle hardware" — callers
 * that need to fetch per-nozzle should fall back to their own default rather
 * than treating it as "no nozzles". Preserves the bare decimal string form the
 * status carries so it can be passed straight to `getKProfiles`.
 */
export function installedNozzleDiameters(
  status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
): string[] {
  const seen = new Set<string>();
  const result: string[] = [];
  for (const nozzle of status?.nozzles ?? []) {
    const raw = (nozzle?.nozzle_diameter ?? '').trim();
    if (!raw || !(parseFloat(raw) > 0) || seen.has(raw)) continue;
    seen.add(raw);
    result.push(raw);
  }
  return result;
}

/**
 * Resolve the installed nozzle diameter feeding a given AMS unit, so the
 * Configure-AMS-Slot picker filters filament presets by the nozzle actually on
 * the machine instead of assuming 0.4mm (#1899).
 *
 * On dual-nozzle printers (H2D) each AMS is bound to one extruder via
 * `ams_extruder_map` (amsId → extruder index, 0=left/primary, 1=right), so we
 * read that nozzle's diameter. Single-nozzle printers have no map entry and
 * fall back to the primary nozzle (index 0). Returns undefined when the printer
 * hasn't reported nozzle hardware yet, letting the caller keep its own default.
 * Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
 */
export function resolveSlotNozzleDiameter(
  status: {
    nozzles?: { nozzle_diameter?: string }[];
    ams_extruder_map?: Record<string, number>;
  } | null | undefined,
  amsId: number,
): string | undefined {
  const nozzles = status?.nozzles;
  if (!nozzles || nozzles.length === 0) return undefined;
  const extruderIdx = status?.ams_extruder_map?.[String(amsId)] ?? 0;
  const diameter = nozzles[extruderIdx]?.nozzle_diameter || nozzles[0]?.nozzle_diameter;
  return diameter || undefined;
}

/**
 * Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
 *
 * Permissive zero-string check: any non-zero non-empty value returns true. The
 * function exists to suppress assign/unassign actions on RFID-managed slots
 * whose state is owned by the printer firmware — manual changes there would be
 * overwritten on the next RFID re-read (eye → pen icon in BambuStudio).
 */
export function isBambuLabSpool(tray: {
  tray_uuid?: string | null;
  tag_uid?: string | null;
} | null | undefined): boolean {
  if (!tray) return false;
  if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') return true;
  if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
  return false;
}

export interface AmsTrayLike {
  id: number;
  tray_type: string | null | undefined;
  tray_sub_brands: string | null | undefined;
  tray_color: string | null | undefined;
  tray_info_idx: string | null | undefined;
}

export interface AmsUnitLike {
  id: number;
  tray: AmsTrayLike[];
}

/**
 * One row in the AMS Backup modal: a group of slots that back each other up
 * (length >= 2), or a single non-empty slot with no peer (length === 1).
 */
export interface BackupGroup {
  /** Stable key — same across renders for the same material+extruder. */
  key: string;
  /** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
  presetId: string | null;
  /** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
  extruder: number;
  /** Display name from the first slot's tray_sub_brands (or tray_type). */
  displayName: string;
  /** Tray colour from the first slot, for the swatch in the modal. */
  trayColor: string | null;
  /** Member slots, in (ams_id, slot_idx) order. */
  members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
}

/**
 * Canonicalise a hex colour for identity comparison. Mirrors the backend
 * `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
 * the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
 */
function normalizeColorForId(raw: string | null | undefined): string {
  let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
  if (s.length === 8) s = s.slice(0, 6);
  return s;
}

/**
 * Compute backup pairs for the AMS Backup modal (#1762).
 *
 * Strict identity rule (mirrors backend `_material_identity_internal` /
 * `_material_identity_spoolman`): slots pair ONLY when they share the same
 * Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
 * preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
 * colour pins the variant — three PETG HF spools in different colours
 * absolutely don't back each other up. User-tagged spools without a preset
 * never pair — Bambu's firmware backup logic relies on the preset, and
 * pairing on cosmetic name/colour match alone would let two visually-
 * identical but materially-different spools be treated as backups.
 *
 * Empty slots are skipped entirely. Every non-empty slot is returned — slots
 * without a peer come back as 1-member entries so the modal can list them as
 * "Slots without a backup peer".
 *
 * On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
 * side — the firmware can't cross extruders even with the global backup bit
 * set.
 */
export function computeBackupGroups(
  amsUnits: AmsUnitLike[] | undefined,
  amsExtruderMap: Record<string, number> | undefined,
  isDualNozzle: boolean,
): BackupGroup[] {
  if (!amsUnits || amsUnits.length === 0) return [];

  // Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
  // observed in the wild to occasionally contain duplicate entries (e.g. on
  // VP-aggregated switch printers or during MQTT partial-update merges). A
  // duplicate would surface as "AMS-A slot 1" rendered twice with different
  // materials, which is impossible physically and visually broken. First
  // occurrence per `ams.id` wins.
  const seenIds = new Set<number>();
  const uniqueAms: AmsUnitLike[] = [];
  for (const ams of amsUnits) {
    if (seenIds.has(ams.id)) continue;
    seenIds.add(ams.id);
    uniqueAms.push(ams);
  }

  const byKey = new Map<string, BackupGroup>();

  for (const ams of uniqueAms) {
    const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
    ams.tray.forEach((tray, slotIdx) => {
      if (!tray?.tray_type) return; // empty slot
      const preset = (tray.tray_info_idx || '').trim();
      const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
      const member = { amsId: ams.id, slotIdx, globalTrayId };

      let key: string;
      let presetId: string | null;
      if (preset) {
        // Same Bambu profile is necessary but NOT sufficient — different colours
        // of the same PETG HF profile can't back each other up. Bake the colour
        // into the identity key, normalised to strip alpha and case.
        const color = normalizeColorForId(tray.tray_color);
        key = `preset:${preset}|color:${color}#${extruder}`;
        presetId = preset;
      } else {
        // No preset → never group with anything else. Unique-per-slot key.
        key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
        presetId = null;
      }

      const existing = byKey.get(key);
      if (existing) {
        existing.members.push(member);
      } else {
        byKey.set(key, {
          key,
          presetId,
          extruder,
          displayName: tray.tray_sub_brands || tray.tray_type || '',
          trayColor: tray.tray_color ?? null,
          members: [member],
        });
      }
    });
  }

  // Stable sort: extruder first (so the modal can section per side on
  // dual-nozzle), then pairs before lone slots, then by name, then by first
  // member's global tray id for deterministic rendering.
  return Array.from(byKey.values()).sort((a, b) => {
    if (a.extruder !== b.extruder) return a.extruder - b.extruder;
    const aLone = a.members.length === 1 ? 1 : 0;
    const bLone = b.members.length === 1 ? 1 : 0;
    if (aLone !== bLone) return aLone - bLone;
    if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
    return a.members[0].globalTrayId - b.members[0].globalTrayId;
  });
}
