/**
 * Regression tests for Cell Identity save behaviour in Settings.jsx.
 *
 * The Settings page uses a global Accept/Discard flow (DraftConfig).
 * Changes accumulate in React state; nothing is sent to the API until
 * the user presses Accept (which calls flushAll → identity flusher →
 * saveIdentity). Auto-save only runs for non-DDNS-registration changes.
 *
 * Covers:
 * - Changing cell_name marks identity dirty (Accept/Discard banner appears)
 * - Auto-save does NOT fire for pic_ngo cell_name changes (requires Accept)
 * - Auto-save DOES fire for ip_range-only changes in pic_ngo mode
 * - Availability check is NOT sent on page load when name is unchanged
 * - Availability check IS sent after the user types a new cell name
 * - saveIdentity (called by the Accept flusher) saves the new cell name
 */
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

// ── API mocks ─────────────────────────────────────────────────────────────────

const mockGetConfig = vi.fn();
const mockUpdateConfig = vi.fn();
const mockListBackups = vi.fn();
const mockGetStatus = vi.fn();
const mockCheckName = vi.fn();
const mockGetCertStatus = vi.fn();
const mockSetDirty = vi.fn();

vi.mock('../services/api', () => ({
  cellAPI: {
    getConfig: (...a) => mockGetConfig(...a),
    updateConfig: (...a) => mockUpdateConfig(...a),
    listBackups: (...a) => mockListBackups(...a),
  },
  ddnsAPI: {
    getStatus: (...a) => mockGetStatus(...a),
    checkName: (...a) => mockCheckName(...a),
  },
  caddyAPI: {
    getCertStatus: (...a) => mockGetCertStatus(...a),
  },
}));

vi.mock('../contexts/ConfigContext', () => ({
  useConfig: () => ({ domain: 'cell', cell_name: 'pic1', refresh: vi.fn() }),
}));

vi.mock('../contexts/DraftConfigContext', () => ({
  useDraftConfig: () => ({
    registerFlusher: vi.fn(() => vi.fn()),
    setDirty: (...a) => mockSetDirty(...a),
    hasDirty: vi.fn(() => false),
    isDirty: false,
    flushAll: vi.fn(),
    clearAllDirty: vi.fn(),
  }),
}));

vi.mock('../utils/serviceConfig', () => ({
  PORT_CONFLICT_FIELDS: {},
  detectPortConflicts: vi.fn(() => ({})),
  validateServiceConfig: vi.fn(() => ({})),
  SERVICE_DEFS: [],
}));

// ── helpers ───────────────────────────────────────────────────────────────────

function makeCfg(overrides = {}) {
  return {
    cell_name: 'pic1',
    domain: 'cell',
    ip_range: '172.20.0.0/16',
    domain_mode: 'pic_ngo',
    domain_name: 'pic1.pic.ngo',
    effective_domain: 'pic1.pic.ngo',
    ddns: { has_token: true },
    service_configs: {},
    installed_services: [],
    ...overrides,
  };
}

function defaultMocks() {
  mockGetConfig.mockResolvedValue({ data: makeCfg() });
  mockUpdateConfig.mockResolvedValue({ data: { warnings: [] } });
  mockListBackups.mockResolvedValue({ data: [] });
  mockGetStatus.mockResolvedValue({ data: { registered: true, public_ip: '1.2.3.4', last_ip: '1.2.3.4' } });
  mockGetCertStatus.mockResolvedValue({ data: null });
  mockCheckName.mockResolvedValue({ data: { available: true } });
}

async function renderSettings() {
  const { default: Settings } = await import('../pages/Settings.jsx');
  let result;
  await act(async () => {
    result = render(<Settings />);
    await Promise.resolve();
    await Promise.resolve();
  });
  await waitFor(() => screen.getByDisplayValue('pic1'));
  return result;
}

// ── tests ─────────────────────────────────────────────────────────────────────

describe('Cell Identity — dirty state triggers Accept/Discard banner', () => {
  beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
  afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });

  it('changing cell_name calls draftConfig.setDirty("identity", true)', async () => {
    await renderSettings();
    mockSetDirty.mockClear();

    fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });

    expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
  });

  it('changing ip_range calls draftConfig.setDirty("identity", true)', async () => {
    await renderSettings();
    mockSetDirty.mockClear();

    fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });

    expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
  });
});

describe('Cell Identity — auto-save behaviour', () => {
  beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
  afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });

  it('auto-save does NOT fire for pic_ngo cell_name changes — Accept is required', async () => {
    mockCheckName.mockResolvedValue({ data: { available: true } });
    await renderSettings();

    fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });

    // Advance well past both debounces (availability 900 ms + auto-save 800 ms)
    await act(async () => { vi.advanceTimersByTime(2500); });
    await act(async () => { await Promise.resolve(); });

    expect(mockUpdateConfig).not.toHaveBeenCalled();
  });

  it('auto-save fires after 800 ms when only ip_range changes (name unchanged)', async () => {
    mockGetConfig
      .mockResolvedValueOnce({ data: makeCfg() })
      .mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) });
    await renderSettings();

    fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });

    await act(async () => { vi.advanceTimersByTime(500); });
    expect(mockUpdateConfig).not.toHaveBeenCalled(); // not yet

    await act(async () => { vi.advanceTimersByTime(400); });
    await act(async () => { await Promise.resolve(); });

    expect(mockUpdateConfig).toHaveBeenCalledOnce();
    expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ ip_range: '10.0.0.0/8' });
  });
});

describe('Cell Identity — availability check', () => {
  beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
  afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });

  it('does NOT check availability on page load (name unchanged)', async () => {
    await renderSettings();
    // Advance past the 900 ms debounce
    await act(async () => { vi.advanceTimersByTime(1200); });
    expect(mockCheckName).not.toHaveBeenCalled();
  });

  it('checks availability after user types a new cell name', async () => {
    await renderSettings();

    fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
    await act(async () => { vi.advanceTimersByTime(950); });
    await act(async () => { await Promise.resolve(); });

    expect(mockCheckName).toHaveBeenCalledWith('pic2');
  });
});

describe('Cell Identity — Accept path (saveIdentity called by flusher)', () => {
  beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
  afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });

  it('saveIdentity saves new cell_name when called directly (simulates Accept press)', async () => {
    mockCheckName.mockResolvedValue({ data: { available: true } });
    mockGetConfig
      .mockResolvedValueOnce({ data: makeCfg() })
      .mockResolvedValue({ data: makeCfg({ cell_name: 'pic2', domain_name: 'pic2.pic.ngo' }) });

    const { default: Settings } = await import('../pages/Settings.jsx');

    // Capture the registered flusher so we can call it like Accept would
    let identityFlusher;
    const mockRegisterFlusher = vi.fn((key, fn) => {
      if (key === 'identity') identityFlusher = fn;
      return vi.fn();
    });
    vi.mocked(await import('../contexts/DraftConfigContext')).useDraftConfig = () => ({
      registerFlusher: mockRegisterFlusher,
      setDirty: mockSetDirty,
      hasDirty: vi.fn(() => true),
      flushAll: vi.fn(),
    });

    await act(async () => {
      render(<Settings />);
      await Promise.resolve();
      await Promise.resolve();
    });
    await waitFor(() => screen.getByDisplayValue('pic1'));

    fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });

    // Confirm availability
    await act(async () => { vi.advanceTimersByTime(950); });
    await act(async () => { await Promise.resolve(); });

    // Simulate Accept press (calls the flusher directly)
    if (identityFlusher) {
      await act(async () => { await identityFlusher(); });
    }

    expect(mockUpdateConfig).toHaveBeenCalledOnce();
    expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ cell_name: 'pic2' });
  });
});

describe('Cell Identity — DraftConfig dirty state is set synchronously on change', () => {
  /**
   * Verifies that draftConfig.setDirty is called in the same synchronous
   * event handler as the identity state change, not in a deferred effect.
   * This is what allows the banner to appear immediately (the isDirty reactive
   * state in DraftConfigContext re-renders App.jsx without waiting for a poll).
   */
  beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
  afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });

  it('setDirty("identity", true) is called before any timer fires after cell_name change', async () => {
    await renderSettings();
    mockSetDirty.mockClear();

    fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });

    // setDirty must be called synchronously within the event, not in a timer
    expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
    expect(mockSetDirty).toHaveBeenCalledTimes(1);
  });

  it('setDirty("identity", true) is called before any timer fires after ip_range change', async () => {
    await renderSettings();
    mockSetDirty.mockClear();

    fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });

    expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
    expect(mockSetDirty).toHaveBeenCalledTimes(1);
  });

  it('setDirty("identity", false) is called after saveIdentity completes', async () => {
    mockGetConfig
      .mockResolvedValueOnce({ data: makeCfg() })
      .mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) });
    await renderSettings();
    mockSetDirty.mockClear();

    fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });

    // auto-save fires after 800 ms
    await act(async () => { vi.advanceTimersByTime(900); });
    await act(async () => { await Promise.resolve(); });

    const calls = mockSetDirty.mock.calls;
    expect(calls.some(([k, v]) => k === 'identity' && v === true)).toBe(true);
    expect(calls.some(([k, v]) => k === 'identity' && v === false)).toBe(true);
  });
});
