/**
 * Tests for the SpoolFormModal weightTouched behavior.
 *
 * Verifies that weight_used is only included in the PATCH payload when the user
 * explicitly changes the remaining weight field. This prevents stale React Query
 * cache values from overwriting usage-tracked weight data on the backend.
 */

import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { SpoolFormModal } from '../../components/SpoolFormModal';
import type { InventorySpool } from '../../api/client';

// Mock the API client
vi.mock('../../api/client', () => ({
  api: {
    getSettings: vi.fn().mockResolvedValue({}),
    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
    getFilamentPresets: vi.fn().mockResolvedValue([]),
    getSpoolCatalog: vi.fn().mockResolvedValue([]),
    getLocations: vi.fn().mockResolvedValue([]),
    getColorCatalog: vi.fn().mockResolvedValue([]),
    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
    getBuiltinFilaments: vi.fn().mockResolvedValue([]),
    getPrinters: vi.fn().mockResolvedValue([]),
    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
    createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
    saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
    updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
    bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
      created: [{ id: 1, material: 'PLA' }],
      requested_count: 1,
      failed_count: 0,
    }),
    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
    getAssignments: vi.fn().mockResolvedValue([]),
    getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
    unassignSpool: vi.fn().mockResolvedValue({}),
    unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
  },
  ApiError: class ApiError extends Error {
    status: number;
    constructor(message: string, status: number) {
      super(message);
      this.status = status;
    }
  },
}));

// Mock validateForm so we can bypass validation for the create-mode test
// (editing tests pass validation naturally since the spool has material + slicer_filament)
vi.mock('../../components/spool-form/types', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
  return {
    ...actual,
    validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
  };
});

// Mock the toast context
const mockShowToast = vi.fn();
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  return {
    ...actual,
    useToast: () => ({ showToast: mockShowToast }),
  };
});

import { api } from '../../api/client';

const existingSpool: InventorySpool = {
  id: 1,
  material: 'PLA',
  subtype: 'Basic',
  brand: 'Polymaker',
  color_name: 'Red',
  rgba: 'FF0000FF',
  extra_colors: null,
  effect_type: null,
  label_weight: 1000,
  core_weight: 250,
  core_weight_catalog_id: null,
  weight_used: 300,
  slicer_filament: 'GFL99',
  slicer_filament_name: 'Generic PLA',
  nozzle_temp_min: null,
  nozzle_temp_max: null,
  note: null,
  added_full: null,
  last_used: null,
  encode_time: null,
  tag_uid: null,
  tray_uuid: null,
  data_origin: null,
  tag_type: null,
  archived_at: null,
  created_at: '2025-01-01T00:00:00Z',
  updated_at: '2025-01-01T00:00:00Z',
  k_profiles: [],
};

describe('SpoolFormModal weightTouched', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('excludes weight_used from PATCH when editing without changing weight', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="edit"
        currencySymbol="$"
      />
    );

    // Wait for the modal to render with the edit title
    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // Click Save without touching the weight field
    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect(spoolId).toBe(1);
    // weight_used must NOT be present in the payload
    expect(payload).not.toHaveProperty('weight_used');
    // Other fields should still be present
    expect(payload).toHaveProperty('material', 'PLA');
    expect(payload).toHaveProperty('label_weight', 1000);
  });

  it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
    // The input is a number input displaying 700. Find it by its displayed value.
    const remainingInput = screen.getByDisplayValue('700');
    expect(remainingInput).toBeInTheDocument();

    // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
    fireEvent.change(remainingInput, { target: { value: '500' } });
    // Blur triggers updateField('weight_used', ...) which sets weightTouched
    fireEvent.blur(remainingInput);

    // Click Save
    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect(spoolId).toBe(1);
    // weight_used MUST be present since the user changed the weight
    expect(payload).toHaveProperty('weight_used', 500);
  });

  it('includes weight_used when creating a new spool', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
      />
    );

    // Wait for the modal to render with the create title
    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
    });

    // Click the submit button (validation is mocked to always pass).
    // The default form data has weight_used=0, and for create mode the condition
    //   if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
    // always includes weight_used since isEditing is false.
    // The submit button also says "Add Spool" — use getAllByText and pick the button.
    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
    expect(submitButton).toBeTruthy();
    fireEvent.click(submitButton!);

    await waitFor(() => {
      expect(api.createSpool).toHaveBeenCalledTimes(1);
    });

    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
    // weight_used MUST be included for new spools (default value 0)
    expect(payload).toHaveProperty('weight_used', 0);
  });

  it('preserves core_weight_catalog_id when editing other fields', async () => {
    const spoolWithCatalogId: InventorySpool = {
      ...existingSpool,
      core_weight_catalog_id: 5,
    };

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithCatalogId}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // Change the note field (unrelated to catalog ID)
    const noteInputs = screen.getAllByPlaceholderText(/note/i);
    expect(noteInputs.length).toBeGreaterThan(0);
    fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });

    // Click Save
    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect(spoolId).toBe(1);
    // core_weight_catalog_id MUST be preserved when editing other fields
    expect(payload).toHaveProperty('core_weight_catalog_id', 5);
    // Other changes should also be present
    expect(payload).toHaveProperty('note', 'Updated note');
  });

  it('includes core_weight_catalog_id when selecting from catalog', async () => {
    const mockCatalog = [
      { id: 1, name: 'Generic 250g', weight: 250 },
      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
      { id: 3, name: 'Standard 300g', weight: 300 },
    ];

    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
    });

    // Wait for catalog to load
    await waitFor(() => {
      expect(api.getSpoolCatalog).toHaveBeenCalled();
    });

    // Click on the empty spool weight field to open dropdown
    const weightInputs = screen.getAllByPlaceholderText(/search/i);
    const weightPicker = weightInputs.find(input =>
      input.getAttribute('placeholder')?.toLowerCase().includes('spool')
    );
    expect(weightPicker).toBeTruthy();
    fireEvent.focus(weightPicker!);

    // Click on "Bambu Lab 250g" option
    const bambuOption = await screen.findByText('Bambu Lab 250g');
    fireEvent.click(bambuOption);

    // Click the add spool button
    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
    expect(submitButton).toBeTruthy();
    fireEvent.click(submitButton!);

    await waitFor(() => {
      expect(api.createSpool).toHaveBeenCalledTimes(1);
    });

    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
    // Both weight AND catalog ID should be sent
    expect(payload).toHaveProperty('core_weight', 250);
    expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
  });

  it('preserves cost_per_kg when editing spool', async () => {
    const spoolWithCost: InventorySpool = {
      ...existingSpool,
      cost_per_kg: 25.50,
    };

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithCost}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // Click Save without changing cost
    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect(spoolId).toBe(1);
    // cost_per_kg should be preserved in the update payload
    expect(payload).toHaveProperty('cost_per_kg', 25.50);
  });

  it('sends null cost_per_kg when spool has no cost', async () => {
    const spoolWithoutCost: InventorySpool = {
      ...existingSpool,
      cost_per_kg: null,
    };

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithoutCost}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    // cost_per_kg should be null when not set
    expect(payload).toHaveProperty('cost_per_kg', null);
  });

  it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
    // #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
    // was editable in the UI but any save 422'd because SpoolUpdate now enforces
    // the 8-char pattern. The form must sanitize the loaded value to a valid
    // default so users can edit unrelated fields without being forced to fix
    // a color they may not even have noticed was broken.
    const spoolWithBadRgba: InventorySpool = {
      ...existingSpool,
      rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
    };

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithBadRgba}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    // The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
    // value loaded from the stale DB row.
    expect(payload).toHaveProperty('rgba');
    expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
    expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
  });

  it('preserves a valid existing rgba on edit (no forced default)', async () => {
    // Sanity: the normalization only kicks in for malformed values. A valid
    // 8-char rgba must round-trip untouched so untouched edits don't quietly
    // reset a user's chosen color.
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool} // rgba = 'FF0000FF' (valid)
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
  });

  it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => {
    vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
      created: [{ id: 1, material: 'PLA' } as InventorySpool],
      requested_count: 3,
      failed_count: 2,
    });

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        mode="create"
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
    });

    // Enable Quick Add mode so the quantity field appears
    const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
    const toggleButton = quickAddRow?.querySelector('button[type="button"]');
    expect(toggleButton).toBeTruthy();
    fireEvent.click(toggleButton!);

    // Set quantity to 3 (triggers bulkCreateMutation instead of createMutation)
    const quantityContainer = screen.getByText('Quantity').closest('div');
    const quantityInput = quantityContainer?.querySelector('input[type="number"]');
    expect(quantityInput).toBeTruthy();
    fireEvent.change(quantityInput!, { target: { value: '3' } });

    // Click the submit button
    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
    expect(submitButton).toBeTruthy();
    fireEvent.click(submitButton!);

    await waitFor(() => {
      expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
    });

    // Should show a warning toast for partial failure (1 created, 2 failed, 3 requested)
    expect(mockShowToast).toHaveBeenCalledWith(
      expect.stringContaining('1 of 3'),
      'warning',
    );
  });

  it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => {
    vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
      created: [
        { id: 1, material: 'PLA' } as InventorySpool,
        { id: 2, material: 'PLA' } as InventorySpool,
        { id: 3, material: 'PLA' } as InventorySpool,
      ],
      requested_count: 3,
      failed_count: 0,
    });

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        mode="create"
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
    });

    // Enable Quick Add mode so the quantity field appears
    const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
    const toggleButton = quickAddRow?.querySelector('button[type="button"]');
    expect(toggleButton).toBeTruthy();
    fireEvent.click(toggleButton!);

    // Set quantity to 3
    const quantityContainer = screen.getByText('Quantity').closest('div');
    const quantityInput = quantityContainer?.querySelector('input[type="number"]');
    expect(quantityInput).toBeTruthy();
    fireEvent.change(quantityInput!, { target: { value: '3' } });

    // Click the submit button
    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
    expect(submitButton).toBeTruthy();
    fireEvent.click(submitButton!);

    await waitFor(() => {
      expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
    });

    // Should show a success toast listing the count of created spools
    expect(mockShowToast).toHaveBeenCalledWith(
      expect.stringContaining('3'),
      'success',
    );
  });

  it('displays correct catalog name when duplicates exist', async () => {
    const spoolWithCatalogId: InventorySpool = {
      ...existingSpool,
      core_weight: 250,
      core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
    };

    const mockCatalog = [
      { id: 1, name: 'Generic 250g', weight: 250 },
      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
      { id: 3, name: 'Standard 300g', weight: 300 },
    ];

    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithCatalogId}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // Wait for catalog to load
    await waitFor(() => {
      expect(api.getSpoolCatalog).toHaveBeenCalled();
    });

    // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
    await waitFor(() => {
      const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
      const bambuFound = weightInputs.some(input =>
        input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
      );
      expect(bambuFound).toBeTruthy();
    });
  });
});

describe('SpoolFormModal Spoolman K-profile support', () => {
  const spoolmanSpool: InventorySpool = {
    ...{
      id: 42,
      material: 'PLA',
      subtype: 'Basic',
      brand: 'BrandX',
      color_name: 'Black',
      rgba: '000000FF',
      label_weight: 1000,
      core_weight: 250,
      core_weight_catalog_id: null,
      weight_used: 200,
      slicer_filament: '',
      slicer_filament_name: '',
      nozzle_temp_min: null,
      nozzle_temp_max: null,
      note: null,
      added_full: null,
      last_used: null,
      encode_time: null,
      tag_uid: null,
      tray_uuid: null,
      data_origin: 'spoolman',
      tag_type: 'spoolman',
      archived_at: null,
      created_at: '2025-01-01T00:00:00Z',
      updated_at: '2025-01-01T00:00:00Z',
      k_profiles: [],
    },
  } as InventorySpool;

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('shows PA Profile tab for Spoolman spools in non-quickAdd mode', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolmanSpool}
        mode="edit"
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // PA Profile tab should be visible in Spoolman mode
    expect(screen.getByText('PA Profile')).toBeInTheDocument();
  });

  it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolmanSpool}
        mode="edit"
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
    });

    // saveSpoolmanKProfiles is always called on update (even with empty list)
    await waitFor(() => {
      expect(api.saveSpoolmanKProfiles).toHaveBeenCalledWith(42, []);
    });
    expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// T2: SpoolmanFilamentPicker integration with SpoolFormModal
// ---------------------------------------------------------------------------

vi.mock('../../components/spool-form/SpoolmanFilamentPicker', () => ({
  SpoolmanFilamentPicker: ({ onSelect, selectedId }: { onSelect: (f: unknown) => void; selectedId: number | null; isLoading: boolean; filaments: unknown[] }) => {
    return (
      <div>
        <span data-testid="picker-selected-id">{selectedId ?? 'none'}</span>
        <button data-testid="picker-select-btn" onClick={() => onSelect({
          id: 7,
          name: 'PLA Basic',
          material: 'PLA',
          color_hex: 'FF0000',
          color_name: 'Red',
          weight: 1000,
          spool_weight: 196,
          vendor: { id: 1, name: 'Bambu Lab' },
        })}>
          Select Filament
        </button>
      </div>
    );
  },
}));

describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('renders SpoolmanFilamentPicker in Spoolman create mode', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
    });
  });

  it('does NOT render SpoolmanFilamentPicker in local inventory mode', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
        spoolmanMode={false}
      />
    );

    await waitFor(() => {
      expect(screen.queryByTestId('picker-select-btn')).not.toBeInTheDocument();
    });
  });

  it('prefills form fields when a filament is selected from the picker', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
    });

    fireEvent.click(screen.getByTestId('picker-select-btn'));

    // After selection, the picker should reflect the selected ID
    await waitFor(() => {
      expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
    });
  });

  it('includes spoolman_filament_id in the submit payload when a filament is pre-selected', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
        spoolmanMode={true}
        spoolsQueryKey={['spoolman-spools']}
      />
    );

    await waitFor(() => {
      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
    });

    // Select a filament
    fireEvent.click(screen.getByTestId('picker-select-btn'));

    // Submit the form
    const saveButton = screen.getByRole('button', { name: /save|add spool/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
    });

    const callArg = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
    expect(callArg.spoolman_filament_id).toBe(7);
  });

  it('clears spoolman_filament_id and shows unlink toast when user edits a linked field', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    await waitFor(() => {
      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
    });

    // Select a filament from the catalog picker
    fireEvent.click(screen.getByTestId('picker-select-btn'));

    await waitFor(() => {
      expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
    });

    // Manually edit the color_name field (a linked field)
    const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
    fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });

    // spoolman_filament_id must be cleared (picker shows 'none')
    await waitFor(() => {
      expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
    });

    // Unlink toast must have been shown
    expect(mockShowToast).toHaveBeenCalledWith(
      expect.stringContaining('catalog link'),
      'info',
    );
  });
});

describe('SpoolFormModal — Unassign button (#1336)', () => {
  const spoolmanSpool: InventorySpool = {
    id: 42,
    material: 'PLA',
    subtype: 'Basic',
    brand: 'BrandX',
    color_name: 'Black',
    rgba: '000000FF',
    extra_colors: null,
    effect_type: null,
    label_weight: 1000,
    core_weight: 250,
    core_weight_catalog_id: null,
    weight_used: 200,
    slicer_filament: '',
    slicer_filament_name: '',
    nozzle_temp_min: null,
    nozzle_temp_max: null,
    note: null,
    added_full: null,
    last_used: null,
    encode_time: null,
    tag_uid: null,
    tray_uuid: null,
    data_origin: 'spoolman',
    tag_type: 'spoolman',
    archived_at: null,
    created_at: '2025-01-01T00:00:00Z',
    updated_at: '2025-01-01T00:00:00Z',
    cost_per_kg: null,
    last_scale_weight: null,
    last_weighed_at: null,
    category: null,
    low_stock_threshold_pct: null,
    k_profiles: [],
  } as InventorySpool;

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('enables Unassign in Spoolman mode when a spoolman_slot_assignment exists for the spool', async () => {
    vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([
      {
        printer_id: 1,
        printer_name: 'Test Printer',
        ams_id: 0,
        tray_id: 2,
        spoolman_spool_id: 42,
        ams_label: 'AMS 1',
      },
    ]);

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolmanSpool}
        mode="edit"
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
    await waitFor(() => {
      expect(unassignBtn).not.toBeDisabled();
    });

    fireEvent.click(unassignBtn);

    await waitFor(() => {
      expect(api.unassignSpoolmanSlot).toHaveBeenCalledWith(42);
    });
    expect(api.unassignSpool).not.toHaveBeenCalled();
  });

  it('keeps Unassign disabled in Spoolman mode when no slot assignment exists', async () => {
    vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);

    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolmanSpool}
        mode="edit"
        currencySymbol="$"
        spoolmanMode={true}
      />
    );

    const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
    // Wait one tick for the (empty) query result to settle so the disabled state is final.
    await waitFor(() => {
      expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
    });
    expect(unassignBtn).toBeDisabled();
  });
});

describe('SpoolFormModal locationIdTouched', () => {
  /**
   * Regression tests for the round-trip bug: saving the edit modal without
   * touching the Storage Location field must NOT include location_id in
   * the PATCH payload, so Spoolman's location field is never overwritten with
   * a stale cached value.
   */
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(api.getLocations).mockResolvedValue([
      { id: 1, name: 'IKEAREGAL', identifier: null, spool_count: 1, created_at: '', updated_at: '' },
      { id: 2, name: 'Shelf B', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
    ]);
  });

  const spoolWithStorageLocation: InventorySpool = {
    ...existingSpool,
    storage_location: 'IKEAREGAL',
    location_id: 1,
  };

  it('excludes storage_location from PATCH when editing without changing it', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithStorageLocation}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // Save without touching the storage location field
    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect(spoolId).toBe(1);
    // storage_location must NOT be in the payload — prevents Spoolman location overwrite
    expect(payload).not.toHaveProperty('storage_location');
    expect(payload).not.toHaveProperty('location_id');
    // Other fields should still be present
    expect(payload).toHaveProperty('material', 'PLA');
  });

  it('includes location_id in PATCH when editing and changing it', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={spoolWithStorageLocation}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });

    // Change storage location via the catalog dropdown
    const locationSelect = screen.getByLabelText(/storage location/i);
    fireEvent.change(locationSelect, { target: { value: '2' } });

    const saveButton = screen.getByRole('button', { name: /save/i });
    fireEvent.click(saveButton);

    await waitFor(() => {
      expect(api.updateSpool).toHaveBeenCalledTimes(1);
    });

    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
    expect(spoolId).toBe(1);
    expect(payload).toHaveProperty('location_id', 2);
    expect(payload).not.toHaveProperty('storage_location');
  });

  it('includes location_id when creating a new spool', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
    });

    // Submit without setting storage_location (validation is mocked to pass)
    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
    expect(submitButton).toBeTruthy();
    fireEvent.click(submitButton!);

    await waitFor(() => {
      expect(api.createSpool).toHaveBeenCalledTimes(1);
    });

    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
    expect(payload).toHaveProperty('location_id', null);
    expect(payload).not.toHaveProperty('storage_location');
  });
});

describe('SpoolFormModal copy mode', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('shows "Copy Spool" as the modal title when spool and mode="copy" are passed', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="copy"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
    });
  });

  it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="copy"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
    });

    // The save button label is "Copy Spool" in copy mode
    const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
      .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
    expect(saveBtn).toBeTruthy();
    fireEvent.click(saveBtn!);

    await waitFor(() => {
      expect(api.createSpool).toHaveBeenCalledTimes(1);
    });
    expect(api.updateSpool).not.toHaveBeenCalled();
  });

  it('resets weight_used to 0 in the create payload when copying a spool with non-zero usage', async () => {
    // existingSpool has weight_used: 300 — must become 0 on copy
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="copy"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
    });

    const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
      .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
    expect(saveBtn).toBeTruthy();
    fireEvent.click(saveBtn!);

    await waitFor(() => {
      expect(api.createSpool).toHaveBeenCalledTimes(1);
    });

    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
    expect((payload as Record<string, unknown>).weight_used).toBe(0);
  });
});

// The "#<id>" affordance in the modal header (#1385) is only meaningful when
// editing an existing spool — there's no ID yet on create, and the copy path
// is producing a new spool too. Guard all three cases so a future refactor
// can't quietly start leaking the source spool's ID into the Copy modal.
describe('SpoolFormModal header spool ID (#1385)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('shows #<id> next to the title when editing an existing spool', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="edit"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
    });
    // existingSpool.id is 1; render as "#1" in the modal header.
    expect(screen.getByText('#1')).toBeInTheDocument();
  });

  it('does not show an ID when creating a new spool', async () => {
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
    });
    // No spool exists yet → header carries no "#..." token.
    expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  });

  it('does not leak the source spool ID when copying', async () => {
    // Copying produces a fresh spool — surfacing the source ID in the
    // "Copy Spool" header would mislead the user into thinking the new
    // spool inherits it.
    render(
      <SpoolFormModal
        isOpen={true}
        onClose={vi.fn()}
        spool={existingSpool}
        mode="copy"
        currencySymbol="$"
      />
    );

    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
    });
    expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  });
});
