/**
 * 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([]),
    getColorCatalog: vi.fn().mockResolvedValue([]),
    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
    getPrinters: vi.fn().mockResolvedValue([]),
    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
  },
}));

// 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',
  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}
        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}
        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}
        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}
        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}
        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}
        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)
        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('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}
        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();
    });
  });
});
