/**
 * Tests for the useFilamentMapping hook and helper functions.
 *
 * Tests the tray_info_idx matching logic that ensures the exact spool
 * selected during slicing is used when multiple trays have identical filament.
 */

import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import {
  buildAmsMapping,
  buildFilamentComparison,
  buildLoadedFilaments,
  computeAmsMapping,
  useFilamentMapping,
} from '../../hooks/useFilamentMapping';
import { effectivePreferLowest } from '../../utils/amsHelpers';
import type { PrinterStatus } from '../../api/client';

// Helper to create a minimal printer status with AMS data
function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {
  return {
    ams,
    vt_tray,
  } as PrinterStatus;
}

describe('buildLoadedFilaments', () => {
  it('returns empty array for undefined status', () => {
    const result = buildLoadedFilaments(undefined);
    expect(result).toEqual([]);
  });

  it('extracts filaments from AMS units', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
        ],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result).toHaveLength(2);
    expect(result[0]).toMatchObject({
      type: 'PLA',
      color: '#FF0000',
      amsId: 0,
      trayId: 0,
      globalTrayId: 0,
      trayInfoIdx: 'GFA00',
    });
    expect(result[1]).toMatchObject({
      type: 'PETG',
      color: '#00FF00',
      globalTrayId: 1,
      trayInfoIdx: 'GFA01',
    });
  });

  it('includes tray_info_idx from AMS trays', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
        ],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result[0].trayInfoIdx).toBe('P4d64437');
  });

  it('handles missing tray_info_idx', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No tray_info_idx
        ],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result[0].trayInfoIdx).toBe('');
  });

  it('includes tray_sub_brands from AMS trays', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic' },
          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte' },
        ],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result[0].traySubBrands).toBe('PLA Basic');
    expect(result[1].traySubBrands).toBe('PLA Matte');
  });

  it('handles missing tray_sub_brands', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
        ],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result[0].traySubBrands).toBe('');
  });

  it('includes tray_sub_brands from external spool', () => {
    const status = createPrinterStatus(
      [],
      [{ tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG HF' }]
    );

    const result = buildLoadedFilaments(status);

    expect(result[0].traySubBrands).toBe('PETG HF');
  });

  it('extracts external spool with tray_info_idx', () => {
    const status = createPrinterStatus(
      [],
      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
    );

    const result = buildLoadedFilaments(status);

    expect(result).toHaveLength(1);
    expect(result[0]).toMatchObject({
      type: 'TPU',
      isExternal: true,
      globalTrayId: 254,
      trayInfoIdx: 'EXT001',
    });
  });

  it('skips empty trays', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
          { id: 1, tray_type: '', tray_color: '' },  // Empty tray
          { id: 2 },  // No tray_type
        ],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result).toHaveLength(1);
    expect(result[0].type).toBe('PLA');
  });

  it('marks AMS-HT units correctly', () => {
    const status = createPrinterStatus([
      {
        id: 128,  // AMS-HT typically has high ID
        tray: [
          { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
        ],  // Single tray = AMS-HT
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result[0].isHt).toBe(true);
    expect(result[0].globalTrayId).toBe(128);  // AMS-HT uses ams_id directly
  });
});

describe('computeAmsMapping', () => {
  it('returns undefined for empty filament requirements', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);

    expect(computeAmsMapping(undefined, status)).toBeUndefined();
    expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
  });

  it('returns undefined when no filaments loaded', () => {
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };

    expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
    expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
  });

  it('matches by tray_info_idx with highest priority', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },  // Same color, wrong idx
          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },  // Exact idx match
          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },  // Same color, wrong idx
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([1]);  // Should pick tray 1, not tray 0
  });

  it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
    // This is the exact scenario from issue #245 - multiple black PLA spools
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
          { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' },  // This one
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([3]);  // Should pick tray 3, not tray 0
  });

  it('falls back to color match when tray_info_idx is empty', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' },  // Wrong color
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' },  // Color match
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([1]);
  });

  it('falls back to color match when tray_info_idx does not match', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' },  // Different idx, same color
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0]);  // Falls back to color match
  });

  it('matches by type only when color differs', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '0000FF' },  // Same type, different color
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0]);  // Type-only match
  });

  it('returns -1 for unmatched slots', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 },  // No TPU loaded
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([-1]);
  });

  it('avoids duplicate tray assignment', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
        { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },  // Same requirements
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // Only one PLA
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0, -1]);  // First slot gets the match, second is unmatched
  });

  it('handles multi-slot mapping with tray_info_idx', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
        { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0, 2]);  // Each slot gets its specific tray
  });

  it('handles external spool matching', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
      ],
    };
    const status = createPrinterStatus(
      [],
      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
    );

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([254]);  // External spool global ID
  });
});

describe('buildLoadedFilaments - nozzle awareness', () => {
  it('sets extruderId from ams_extruder_map', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
      {
        id: 1,
        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };

    const result = buildLoadedFilaments(status);

    expect(result[0].extruderId).toBe(1);  // AMS 0 → left nozzle
    expect(result[1].extruderId).toBe(0);  // AMS 1 → right nozzle
  });

  it('leaves extruderId undefined when no ams_extruder_map', () => {
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);

    const result = buildLoadedFilaments(status);

    expect(result[0].extruderId).toBeUndefined();
  });
});

describe('computeAmsMapping - nozzle filtering', () => {
  it('filters candidates by nozzle_id when set', () => {
    // Filament requires left nozzle (extruder 1), only AMS 0 is on left
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,  // Left nozzle
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
      {
        id: 1,  // Right nozzle
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0]);  // AMS 0, tray 0 (on left nozzle)
  });

  it('filters to right nozzle when nozzle_id=0', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,  // Left nozzle
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
      {
        id: 1,  // Right nozzle
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
  });

  it('returns -1 when target nozzle has no trays (hard filter)', () => {
    // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
    // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,  // Right nozzle only
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 0 };  // AMS 0 → right nozzle, none on left

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([-1]);  // Hard filter: no fallback to wrong nozzle
  });

  it('stays restricted when target nozzle has trays but wrong type', () => {
    // Left nozzle has PETG, right has PLA — but requires PLA on left
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,  // Left nozzle - only PETG
        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
      },
      {
        id: 1,  // Right nozzle - has PLA
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([-1]);  // No PLA on left nozzle, stays restricted
  });

  it('skips nozzle filtering when nozzle_id is undefined', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },  // No nozzle_id
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
      },
      {
        id: 1,
        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([4]);  // Picks best match regardless of nozzle
  });

  it('handles dual-nozzle multi-slot mapping', () => {
    // Two filaments: one for left, one for right
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },  // Left
        { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,  // Left nozzle
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
        ],
      },
      {
        id: 1,  // Right nozzle
        tray: [
          { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
        ],
      },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
  });

  // FTS (Filament Track Switch) — when present, AMS slots aren't tied to a
  // specific extruder. The track switch routes any slot to either extruder, so
  // the per-nozzle hard filter must NOT apply. See #1162.
  it('ignores nozzle_id when FTS is installed', () => {
    // Required filament asks for nozzle 1 (left). Without FTS this would force
    // AMS 0 (which is on the left nozzle). With FTS we accept any AMS slot
    // matching by type/color since the FTS routes it to whichever extruder.
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 1 },
      ],
    };
    const status = createPrinterStatus([
      {
        id: 0,  // Without FTS, this AMS would be left/extruder 1; ams_extruder_map
                // is empty because the printer reports info bits 8-11 = 0xE.
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
          { id: 1, tray_type: 'PETG', tray_color: '00FF00' },
        ],
      },
    ]);
    (status as any).ams_extruder_map = {};
    (status as any).fila_switch = {
      installed: true,
      in_slots: [-1, 1],
      out_extruders: [0, 1],
      stat: 0,
      info: 2,
    };

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([1]);  // Picks AMS 0 tray 1 (PETG green) regardless of nozzle
  });

  // X2D / H2D / X2 Pro with no AMS but two external spools (one feeding each
  // extruder). Pre-fix, dual-nozzle was inferred from `ams_extruder_map` being
  // non-empty, which fails when there are no AMS units — both vt_tray entries
  // got `extruderId=undefined`, the per-nozzle filter rejected everything, and
  // the UI surfaced "Required filament type not found in printer" even though
  // the matching filament was sitting in the external spool. (#1257)
  it('matches external spools per-extruder on dual-nozzle without AMS', () => {
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, nozzle_id: 1 },  // Left
      ],
    };
    const status = createPrinterStatus([], [
      // Two external spools, both PETG. Ext-L (id=254) feeds left extruder (1),
      // Ext-R (id=255) feeds right (0). 255 - id formula in buildLoadedFilaments
      // routes them when hasDualNozzle is true.
      { id: 254, tray_type: 'PETG', tray_color: 'FFFFFF' } as PrinterStatus['vt_tray'][number],
      { id: 255, tray_type: 'PETG', tray_color: '000000' } as PrinterStatus['vt_tray'][number],
    ]);
    // Real X2D hardware: both nozzles report a populated diameter via the
    // MQTT right_nozzle_diameter / left_nozzle_diameter fields. ams_extruder_map
    // is empty because there are zero AMS units.
    (status as any).nozzles = [
      { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
      { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
    ];
    (status as any).ams_extruder_map = {};

    // Loaded filaments must surface extruderId on each external entry,
    // otherwise computeAmsMapping's per-nozzle filter strips them out.
    const loaded = buildLoadedFilaments(status);
    expect(loaded).toHaveLength(2);
    expect(loaded.find((f) => f.globalTrayId === 254)?.extruderId).toBe(1);  // Ext-L → left
    expect(loaded.find((f) => f.globalTrayId === 255)?.extruderId).toBe(0);  // Ext-R → right

    // Mapping must succeed and pick Ext-L (left extruder, white PETG).
    const result = computeAmsMapping(reqs, status);
    expect(result).toEqual([254]);
  });

  // Sibling regression: the bambu_mqtt state defaults `nozzles` to a 2-entry
  // list with empty NozzleInfo() stubs even on single-nozzle printers, and the
  // route emits both entries on the wire. The dual-nozzle inference must NOT
  // be tripped by a stub second entry — only by populated hardware info,
  // populated ams_extruder_map, or >1 external trays. Pin: single-nozzle
  // printer (P1S/A1/X1C) with one external spool gets extruderId=undefined,
  // matching pre-fix behaviour. (#1257)
  it('does not fabricate extruderId for single-nozzle with stub nozzles[1]', () => {
    const status = createPrinterStatus([], [
      { id: 254, tray_type: 'PLA', tray_color: 'FF0000' } as PrinterStatus['vt_tray'][number],
    ]);
    // Single-nozzle: nozzles[1] is the default stub (empty fields).
    (status as any).nozzles = [
      { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
      { nozzle_type: '', nozzle_diameter: '' },
    ];
    (status as any).ams_extruder_map = {};

    const loaded = buildLoadedFilaments(status);
    expect(loaded).toHaveLength(1);
    expect(loaded[0].extruderId).toBeUndefined();
  });

  it('still applies nozzle filter when FTS object is null', () => {
    // Sanity check: explicit null fila_switch behaves like no FTS — nozzle
    // filter still applies on real dual-nozzle printers.
    const reqs = {
      filaments: [
        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
      ],
    };
    const status = createPrinterStatus([
      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
      { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
    ]);
    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
    (status as any).fila_switch = null;

    const result = computeAmsMapping(reqs, status);

    expect(result).toEqual([0]);  // AMS 0 (left/extruder 1)
  });
});

// ============================================================================
// MODEL-SPECIFIC TESTS: Real data from actual printers
// ============================================================================

/**
 * H2D real data fixture (from live API response 2026-02-18).
 *
 * Configuration:
 *   LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
 *   RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
 *   External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
 *
 * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
 */
function createH2DStatus(): PrinterStatus {
  const status = createPrinterStatus(
    [
      {
        id: 0, // LEFT nozzle (extruder 1)
        humidity: 24,
        temp: 21.4,
        tray: [
          { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
          { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
          { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
          { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
        ],
      },
      {
        id: 1, // RIGHT nozzle (extruder 0)
        humidity: 25,
        temp: 21.7,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
          { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
          { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
          { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
        ],
      },
      {
        id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
        humidity: 48,
        temp: 21.4,
        tray: [
          { id: 0 }, // empty tray
        ],
      },
      {
        id: 2, // LEFT nozzle (extruder 1)
        humidity: 18,
        temp: 24.0,
        tray: [
          { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
          { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
          { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
          { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
        ],
      },
    ],
    [
      { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
      { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
    ]
  );
  (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
  return status;
}

/**
 * X1C real data fixture (from live API response 2026-02-18).
 *
 * Configuration:
 *   Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
 *   External: 254 (single)
 *
 * ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
 */
function createX1CStatus(): PrinterStatus {
  const status = createPrinterStatus(
    [
      {
        id: 0,
        humidity: 23,
        temp: 26.1,
        tray: [
          { id: 0 }, // empty (has tray_color but no tray_type)
          { id: 1 }, // empty
          { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
          { id: 3 }, // empty
        ],
      },
      {
        id: 1,
        humidity: 20,
        temp: 25.9,
        tray: [
          { id: 0 }, // empty
          { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
          { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
          { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
        ],
      },
    ],
    [
      { id: 254, tray_type: '', tray_color: '00000000' }, // empty
    ]
  );
  (status as any).ams_extruder_map = { '0': 0, '1': 0 };
  return status;
}

describe('H2D model tests (dual nozzle, real data)', () => {
  describe('buildLoadedFilaments', () => {
    it('assigns correct extruderId to all AMS units', () => {
      const result = buildLoadedFilaments(createH2DStatus());

      // AMS 0 trays → extruder 1 (LEFT)
      const ams0 = result.filter((f) => f.amsId === 0);
      expect(ams0).toHaveLength(4);
      ams0.forEach((f) => expect(f.extruderId).toBe(1));

      // AMS 1 trays → extruder 0 (RIGHT)
      const ams1 = result.filter((f) => f.amsId === 1);
      expect(ams1).toHaveLength(4);
      ams1.forEach((f) => expect(f.extruderId).toBe(0));

      // AMS 2 trays → extruder 1 (LEFT)
      const ams2 = result.filter((f) => f.amsId === 2);
      expect(ams2).toHaveLength(4);
      ams2.forEach((f) => expect(f.extruderId).toBe(1));
    });

    it('computes correct globalTrayId for all AMS types', () => {
      const result = buildLoadedFilaments(createH2DStatus());

      // Regular AMS: amsId * 4 + trayId
      expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
      expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
      expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
      expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
      expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
    });

    it('skips empty AMS-HT tray (no tray_type)', () => {
      const result = buildLoadedFilaments(createH2DStatus());
      // AMS-HT 128 is empty in real data — should be skipped
      const ht = result.filter((f) => f.amsId === 128);
      expect(ht).toHaveLength(0);
    });

    it('includes loaded external spool with correct extruder', () => {
      const result = buildLoadedFilaments(createH2DStatus());
      const ext = result.filter((f) => f.isExternal);
      // Only Ext-L (254) has filament, Ext-R (255) is empty
      expect(ext).toHaveLength(1);
      expect(ext[0].globalTrayId).toBe(254);
      expect(ext[0].type).toBe('PLA');
      // Ext-L (254) should be LEFT nozzle (extruder 1)
      expect(ext[0].extruderId).toBe(1);
    });

    it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
      const result = buildLoadedFilaments(createH2DStatus());
      // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
      expect(result).toHaveLength(13);
    });
  });

  describe('computeAmsMapping', () => {
    it('matches left-nozzle filament to left-nozzle AMS only', () => {
      const reqs = {
        filaments: [
          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
        ],
      };
      const result = computeAmsMapping(reqs, createH2DStatus());
      // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
      expect(result).toEqual([3]);
    });

    it('matches right-nozzle filament to right-nozzle AMS only', () => {
      const reqs = {
        filaments: [
          { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
        ],
      };
      const result = computeAmsMapping(reqs, createH2DStatus());
      // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
      expect(result).toEqual([4]);
    });

    it('rejects cross-nozzle assignment (right requires type only on left)', () => {
      const reqs = {
        filaments: [
          // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
          { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
        ],
      };
      const result = computeAmsMapping(reqs, createH2DStatus());
      expect(result).toEqual([-1]); // No fallback to wrong nozzle
    });

    it('maps dual-nozzle multi-filament print correctly', () => {
      const reqs = {
        filaments: [
          // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
          { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
          // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
          { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
        ],
      };
      const result = computeAmsMapping(reqs, createH2DStatus());
      expect(result).toEqual([0, 4]);
    });

    it('matches external spool on correct nozzle', () => {
      const reqs = {
        filaments: [
          // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
        ],
      };
      const result = computeAmsMapping(reqs, createH2DStatus());
      expect(result).toEqual([254]); // External spool on left nozzle
    });
  });
});

describe('X1C model tests (single nozzle, real data)', () => {
  describe('buildLoadedFilaments', () => {
    it('assigns all filaments to extruder 0', () => {
      const result = buildLoadedFilaments(createX1CStatus());
      result.forEach((f) => expect(f.extruderId).toBe(0));
    });

    it('computes correct globalTrayId for regular AMS', () => {
      const result = buildLoadedFilaments(createX1CStatus());
      // AMS 1 T2 (tray id 1) → globalTrayId 5
      expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
      // AMS 1 T3 (tray id 2) → globalTrayId 6
      expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
      // AMS 1 T4 (tray id 3) → globalTrayId 7
      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
    });

    it('returns only loaded trays (3 from AMS 1)', () => {
      const result = buildLoadedFilaments(createX1CStatus());
      // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
      expect(result).toHaveLength(3);
    });
  });

  describe('computeAmsMapping', () => {
    it('matches single-nozzle file without nozzle filtering', () => {
      const reqs = {
        filaments: [
          { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
        ],
      };
      const result = computeAmsMapping(reqs, createX1CStatus());
      // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
      expect(result).toEqual([7]);
    });

    it('matches by tray_info_idx across AMS units', () => {
      const reqs = {
        filaments: [
          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
        ],
      };
      const result = computeAmsMapping(reqs, createX1CStatus());
      // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
      expect(result).toEqual([5]);
    });

    it('handles non-unique tray_info_idx with color matching', () => {
      // P4d64437 appears in both AMS 1 T3 and T4
      const reqs = {
        filaments: [
          { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
        ],
      };
      const result = computeAmsMapping(reqs, createX1CStatus());
      // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
      expect(result).toEqual([6]);
    });

    it('does not cross-nozzle filter for single-nozzle printer', () => {
      // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
      const reqs = {
        filaments: [
          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
          { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
        ],
      };
      const result = computeAmsMapping(reqs, createX1CStatus());
      // Both should match freely across all AMS units
      expect(result).toEqual([5, 7]);
    });
  });
});

describe('computeAmsMapping preferLowest', () => {
  it('picks spool with lowest remain when enabled', () => {
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status, true);
    expect(result).toEqual([1]); // Tray 1 has 25% remain
  });

  it('picks first match when disabled', () => {
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status, false);
    expect(result).toEqual([0]); // First match (default)
  });

  it('sorts unknown remain to end', () => {
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No remain (defaults to -1)
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 },
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status, true);
    expect(result).toEqual([1]); // Known 60% over unknown
  });
});

// #1766: the user reported that "Prefer lowest remaining filament" picked the
// wrong spool when two identical-material/color spools differed only in the
// inventory-tracked grams (not the printer's `remain%`). The pre-fix sort
// looked at `remain%` only and ignored Bambuddy's bound inventory entirely;
// now we pass a globalTrayId -> grams map and the sort lifts inventory-bound
// spools to tier 0 (matching backend _prefer_lowest_sort_key).
describe('computeAmsMapping preferLowest with inventory map (#1766)', () => {
  it('picks spool with lower inventory grams when both spools report same remain%', () => {
    // Reporter's scenario: two identical Bambu-branded spools, both report
    // `remain=100` because they were freshly inserted, but inventory has them
    // at 950 g vs 50 g remaining. Pre-fix sort ties and picks the first.
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
        ],
      },
    ]);
    const inventory = new Map<number, number>([[0, 950], [1, 50]]);

    const result = computeAmsMapping(reqs, status, true, inventory);
    expect(result).toEqual([1]); // Inventory says tray 1 is nearly empty — use it first.
  });

  it('prefers inventory-tracked spool over non-tracked one even when remain% would order them differently', () => {
    // Two spools both match by type+color, both have the same tray_info_idx
    // (identical SKU). Tray 0 has no inventory binding but reports remain=20.
    // Tray 1 has an inventory binding with 100 g remaining. Tier 0 (bound)
    // always beats tier 1 (MQTT-only) regardless of value — matches backend.
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 20 },
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 80 },
        ],
      },
    ]);
    const inventory = new Map<number, number>([[1, 100]]); // Only tray 1 bound

    const result = computeAmsMapping(reqs, status, true, inventory);
    expect(result).toEqual([1]);
  });

  it('falls back to remain% sort when no inventory map provided (pre-#1766 behaviour)', () => {
    // Regression guard: callers that haven't yet wired the map must get the
    // same sort they always got. None of the existing tests in this file pass
    // a map; this asserts the default path is unchanged.
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
        ],
      },
    ]);

    const result = computeAmsMapping(reqs, status, true, undefined);
    expect(result).toEqual([1]); // Same as the existing no-inventory case.
  });
});

// #1766 safety gate: when the printer has AMS Filament Backup OFF, the sort
// MUST NOT run, even with the user setting on. Otherwise the dispatch picks a
// near-empty spool the printer can't switch off of when it runs out mid-print.
// Mirrors backend `_compute_ams_mapping_for_printer` gate.
describe('effectivePreferLowest gate (#1766)', () => {
  it('coerces to false when backup is OFF', () => {
    expect(effectivePreferLowest(true, false)).toBe(false);
  });

  it('passes through when backup is ON', () => {
    expect(effectivePreferLowest(true, true)).toBe(true);
  });

  it('passes through when backup is unknown (null/undefined — A1 family)', () => {
    expect(effectivePreferLowest(true, null)).toBe(true);
    expect(effectivePreferLowest(true, undefined)).toBe(true);
  });

  it('stays false when the user setting is off, regardless of backup state', () => {
    expect(effectivePreferLowest(false, true)).toBe(false);
    expect(effectivePreferLowest(false, false)).toBe(false);
    expect(effectivePreferLowest(undefined, true)).toBe(false);
  });

  it('slot-priority tie-break: external/VT spools sort AFTER regular AMS', () => {
    // Mirrors backend `_slot_priority` banding. When tier and value tie, slot
    // position decides — external (ams_id = -1) must clamp to 10_000 so it
    // can't beat AMS slot 0 (priority 0).
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };
    const status = createPrinterStatus(
      [
        {
          id: 0,
          tray: [
            { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 },  // priority 0
          ],
        },
      ],
      [{ id: 254, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }],  // priority 10_000
    );
    const result = computeAmsMapping(reqs, status, true);
    expect(result).toEqual([0]); // AMS slot wins the tie; VT does not.
  });

  it('end-to-end: backup OFF prevents lowest-pick at dispatch (caller-coerced)', () => {
    // PrintModal computes the effective flag and passes it to computeAmsMapping.
    // This pins that flow: with backup=false the flag becomes false, the sort
    // doesn't run, and the first matching tray wins (today's behaviour).
    const reqs = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
    };
    const status = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 5 },  // near-empty
        ],
      },
    ]);
    const gated = effectivePreferLowest(true, false);
    const result = computeAmsMapping(reqs, status, gated);
    expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected.
  });
});

describe('per-plate mapping vs. the whole-file union (#2551 follow-up)', () => {
  // The multi-plate case that made this necessary: two plates, each printing one
  // red object, but on different slots of the same file. The printer has exactly
  // one red spool.
  const PLATE_1 = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }] };
  const PLATE_2 = { filaments: [{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }] };
  const WHOLE_FILE_UNION = {
    filaments: [
      { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
      { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },
    ],
  };

  const printer = createPrinterStatus([
    {
      id: 0,
      tray: [
        { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // the only red — global tray 0
        { id: 1, tray_type: 'PLA', tray_color: '000000' }, // black — global tray 1
      ],
    },
  ]);

  const mapPlate = (reqs: { filaments: { slot_id: number; type: string; color: string; used_grams: number }[] }) =>
    buildAmsMapping(buildFilamentComparison(reqs, buildLoadedFilaments(printer), {}));

  it('maps each plate to the red tray, because each plate is its own print', () => {
    expect(mapPlate(PLATE_1)).toEqual([0]);
    // Slot 1 is unused by this plate, so it stays -1; slot 2 takes the red tray.
    expect(mapPlate(PLATE_2)).toEqual([-1, 0]);
  });

  it('the union starves the second slot — which is what a shared mapping sent', () => {
    // Tray assignment is stateful: slot 1 claims the red tray, so slot 2 falls
    // through to a type-only match on the BLACK tray. Sending this to plate 2
    // prints it in black. This is the mapping the modal used to post for every
    // plate of a multi-plate submission.
    expect(mapPlate(WHOLE_FILE_UNION)).toEqual([0, 1]);
  });

  it('a manual override on one plate does not leak into another', () => {
    const loaded = buildLoadedFilaments(printer);
    // The user pins plate 2's slot 2 to the black tray by hand.
    const plate2 = buildAmsMapping(buildFilamentComparison(PLATE_2, loaded, { 2: 1 }));
    expect(plate2).toEqual([-1, 1]);
    // Plate 1 is mapped from its own (empty) override set and still gets red.
    expect(buildAmsMapping(buildFilamentComparison(PLATE_1, loaded, {}))).toEqual([0]);
  });
});

describe('useFilamentMapping — no [-1] mapping during a status-load race (#2589)', () => {
  // The file requires one PETG slot; two compatible PETG spools are loaded once
  // the printer status arrives.
  const filamentReqs = {
    filaments: [{ slot_id: 1, type: 'PETG', color: '#161616', used_grams: 141.86 }],
  };
  const statusWithPetg = createPrinterStatus([
    {
      id: 0,
      tray: [
        { id: 0, tray_type: 'PETG', tray_color: 'BCBCBC' },
        { id: 1, tray_type: 'PETG', tray_color: 'FFFFFF' },
      ],
    },
  ]);

  it('returns undefined (not [-1]) while printerStatus is still loading', () => {
    // printerStatus undefined = query not resolved yet. Serializing [-1] here is
    // exactly what dispatched the P1S print to the empty external feed.
    const { result } = renderHook(() => useFilamentMapping(filamentReqs, undefined, {}));
    expect(result.current.amsMapping).toBeUndefined();
  });

  it('resolves to an AMS tray once status has loaded (type-only match, strict color off)', () => {
    // Black requested, only gray/white PETG loaded: a type-only match is valid.
    const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPetg, {}));
    expect(result.current.amsMapping).toEqual([0]);
    expect(result.current.hasTypeMismatch).toBe(false);
  });

  it('still emits [-1] for a genuine mismatch when trays are present', () => {
    // Status loaded but nothing compatible (only PLA) -> the mapping legitimately
    // carries -1 and the mismatch is surfaced; this must NOT be suppressed.
    const statusWithPla = createPrinterStatus([
      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
    ]);
    const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPla, {}));
    expect(result.current.amsMapping).toEqual([-1]);
    expect(result.current.hasTypeMismatch).toBe(true);
  });
});

describe('colour verdict is independent of how the tray was found (#2687)', () => {
  // tray_info_idx names the filament *variant*, not an individual spool:
  // GFA00 = PLA Basic, GFA01 = PLA Matte, GFA17 = PLA Translucent. A user with
  // exactly one Matte spool loaded therefore idx-matches every Matte
  // requirement no matter what colour it is.
  const MATTE_DARK_GREEN = createPrinterStatus([
    { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }] },
  ]);
  const wantRedMatte = {
    filaments: [
      { slot_id: 1, type: 'PLA', color: '#9D432C', used_grams: 31, tray_info_idx: 'GFA01' },
    ],
  };

  it('reports a unique-idx tray of the wrong colour as type_only, not match', () => {
    const [item] = buildFilamentComparison(
      wantRedMatte,
      buildLoadedFilaments(MATTE_DARK_GREEN),
      {},
    );

    // The tray is still selected — it is the right variant (#2650) ...
    expect(item.loaded?.globalTrayId).toBe(0);
    // ... but red-on-dark-green is not a colour match.
    expect(item.colorMatch).toBe(false);
    expect(item.status).toBe('type_only');
  });

  it('auto and manual agree on the same tray', () => {
    const loaded = buildLoadedFilaments(MATTE_DARK_GREEN);
    const auto = buildFilamentComparison(wantRedMatte, loaded, {})[0];
    const manual = buildFilamentComparison(wantRedMatte, loaded, { 1: 0 })[0];

    // The original report: auto said "match", manually picking that very tray
    // said "mismatch". Both paths must now reach the same verdict.
    expect(manual.isManual).toBe(true);
    expect(auto.status).toBe(manual.status);
    expect(auto.colorMatch).toBe(manual.colorMatch);
  });

  it('surfaces the mismatch through the hook so the panel stops saying Ready', () => {
    const { result } = renderHook(() => useFilamentMapping(wantRedMatte, MATTE_DARK_GREEN, {}));
    // hasColorMismatch drives the yellow "(Color mismatch)" header; the tray is
    // still mapped, so this is not a type mismatch.
    expect(result.current.hasColorMismatch).toBe(true);
    expect(result.current.hasTypeMismatch).toBe(false);
    expect(result.current.amsMapping).toEqual([0]);
  });

  it('still reports a match when the unique-idx tray does carry the right colour', () => {
    const [item] = buildFilamentComparison(
      { filaments: [{ slot_id: 1, type: 'PLA', color: '#004225', used_grams: 31, tray_info_idx: 'GFA01' }] },
      buildLoadedFilaments(MATTE_DARK_GREEN),
      {},
    );
    expect(item.status).toBe('match');
    expect(item.colorMatch).toBe(true);
  });

  it('accepts a near-enough shade on the idx path', () => {
    // Within colorsAreSimilar's per-channel tolerance — the printer reporting a
    // spool a shade off must not become a mismatch.
    const [item] = buildFilamentComparison(
      { filaments: [{ slot_id: 1, type: 'PLA', color: '#0A4A2A', used_grams: 31, tray_info_idx: 'GFA01' }] },
      buildLoadedFilaments(MATTE_DARK_GREEN),
      {},
    );
    expect(item.status).toBe('match');
  });

  it('treats a colourless requirement as satisfied by any colour', () => {
    // 3MFs that omit the colour parse to "" (filament_requirements.py); there is
    // nothing to disagree with, so this must not read as a colour mismatch.
    const [item] = buildFilamentComparison(
      { filaments: [{ slot_id: 1, type: 'PLA', color: '', used_grams: 31, tray_info_idx: 'GFA01' }] },
      buildLoadedFilaments(MATTE_DARK_GREEN),
      {},
    );
    expect(item.status).toBe('match');
    expect(item.colorMatch).toBe(true);
  });

  it('keeps the multi-idx path intact — same idx, several colours picks the right one', () => {
    // Two Matte spools: the branch that already compared colours must be
    // unaffected, and the exact-colour tray still wins.
    const twoMatte = createPrinterStatus([
      {
        id: 0,
        tray: [
          { id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' },
          { id: 1, tray_type: 'PLA', tray_color: '9D432C', tray_info_idx: 'GFA01' },
        ],
      },
    ]);
    const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(twoMatte), {});
    expect(item.loaded?.globalTrayId).toBe(1);
    expect(item.status).toBe('match');
  });

  it('a type-only fallback with no idx candidate is still type_only', () => {
    // Regression guard: the pre-existing "type matches, colour does not" path.
    const basicOnly = createPrinterStatus([
      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA00' }] },
    ]);
    const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(basicOnly), {});
    expect(item.status).toBe('type_only');
    expect(item.colorMatch).toBe(false);
  });
});

describe('filament type equivalence groups reach the matcher', () => {
  // The scheduler has always canonicalised types before comparing, so PA12-CF
  // satisfies a PA-CF requirement at dispatch. The interface compared the raw
  // strings, so it called the same pairing a type mismatch — the badge
  // contradicted what the printer would actually do, and the manual override
  // picker (which groups by canonical type) offered the very spool the badge
  // then rejected. Both sides now use `filamentTypesCompatible`.
  const wantPaCf = {
    filaments: [{ slot_id: 1, type: 'PA-CF', color: '#1A1A1A', used_grams: 20 }],
  };
  const pa12Loaded = createPrinterStatus([
    { id: 0, tray: [{ id: 0, tray_type: 'PA12-CF', tray_color: '1A1A1AFF' }] },
  ]);

  it('matches PA12-CF against a PA-CF requirement', () => {
    const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {});
    expect(item.loaded?.globalTrayId).toBe(0);
    expect(item.status).toBe('match');
  });

  it('agrees with the mapping the request is built from', () => {
    expect(
      buildAmsMapping(buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {})),
    ).toEqual([0]);
  });

  it('still refuses a genuinely different material', () => {
    const petg = createPrinterStatus([
      { id: 0, tray: [{ id: 0, tray_type: 'PETG', tray_color: '1A1A1AFF' }] },
    ]);
    const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(petg), {});
    expect(item.status).toBe('mismatch');
  });

  it('does not treat a product variant as an alias for the base material', () => {
    // "PLA Silk" dries like PLA but does not print like it, so the matcher
    // must not substitute one for the other. Matches the backend's rule.
    const silk = createPrinterStatus([
      { id: 0, tray: [{ id: 0, tray_type: 'PLA Silk', tray_color: 'FFFFFFFF' }] },
    ]);
    const wantPla = {
      filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 20 }],
    };
    const [item] = buildFilamentComparison(wantPla, buildLoadedFilaments(silk), {});
    expect(item.status).toBe('mismatch');
  });
});
