import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const {
  DOWNLOAD_MANAGER_MODULE,
  MODAL_MANAGER_MODULE,
  UI_HELPERS_MODULE,
  STATE_MODULE,
  LOADING_MANAGER_MODULE,
  API_FACTORY_MODULE,
  STORAGE_HELPERS_MODULE,
  FOLDER_TREE_MANAGER_MODULE,
  I18N_HELPERS_MODULE,
  SUMMARY_MODULE,
  mockApiClient,
  mockLoadingManager,
  showToastMock,
  showDownloadBatchSummaryMock,
  resetAndReloadMock,
} = vi.hoisted(() => {
  // Shared API client returned by the mocked getModelApiClient factory.
  const mockApiClient = {
    apiConfig: {
      config: {
        displayName: 'LoRA',
        singularName: 'lora',
      },
    },
    downloadModel: vi.fn(),
    downloadHfModel: vi.fn(),
    cancelDownload: vi.fn(),
    getPageState: vi.fn(() => ({})),
  };

  // Shared loading manager served both via state.loadingManager and the
  // LoadingManager constructor mock.
  const mockLoadingManager = {
    showSimpleLoading: vi.fn(),
    hide: vi.fn(),
    restoreProgressBar: vi.fn(),
    showDownloadProgress: vi.fn(() => vi.fn()),
    setStatus: vi.fn(),
    showCancelButton: vi.fn(),
  };

  return {
    DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
    MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
    UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
    STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
    LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
    API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
    STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
    FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
    I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
    SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
    mockApiClient,
    mockLoadingManager,
    showToastMock: vi.fn(),
    showDownloadBatchSummaryMock: vi.fn(),
    resetAndReloadMock: vi.fn(),
  };
});

vi.mock(MODAL_MANAGER_MODULE, () => ({
  modalManager: {
    showModal: vi.fn(),
    closeModal: vi.fn(),
  },
}));

vi.mock(UI_HELPERS_MODULE, () => ({
  showToast: showToastMock,
}));

vi.mock(STATE_MODULE, () => ({
  state: {
    global: {
      settings: {},
    },
    loadingManager: mockLoadingManager,
  },
}));

vi.mock(LOADING_MANAGER_MODULE, () => ({
  LoadingManager: vi.fn(() => mockLoadingManager),
}));

vi.mock(API_FACTORY_MODULE, () => ({
  getModelApiClient: vi.fn(() => mockApiClient),
  resetAndReload: resetAndReloadMock,
}));

vi.mock(STORAGE_HELPERS_MODULE, () => ({
  getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
  setStorageItem: vi.fn(),
}));

vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
  FolderTreeManager: vi.fn(() => ({
    clearSelection: vi.fn(),
    init: vi.fn(),
  })),
}));

vi.mock(I18N_HELPERS_MODULE, () => ({
  translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));

vi.mock(SUMMARY_MODULE, () => ({
  showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));

/**
 * Fake WebSocket used by executeBatchDownload. Resolves `onopen` on the
 * microtask queue right after construction (which happens after the real
 * code has assigned `onopen`), so the open promise resolves deterministically
 * without real timers.
 */
class FakeWebSocket {
  static instances = [];

  constructor(url) {
    this.url = url;
    this.onopen = null;
    this.onmessage = null;
    this.onerror = null;
    this.close = vi.fn();
    FakeWebSocket.instances.push(this);
    queueMicrotask(() => {
      if (this.onopen) this.onopen();
    });
  }

  static get lastInstance() {
    return FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
  }
}

describe('DownloadManager batch download summary flow', () => {
  let DownloadManager;
  let manager;

  const options = { modelRoot: '/models/loras', targetFolder: '', useDefaultPaths: true };

  const makeItem = (modelId, versionId, name) => ({
    modelId,
    displayName: name,
    selectedVersion: { id: versionId, name, existsLocally: false },
  });

  const item0 = makeItem('111', 'v1', 'Model A');
  const item1 = makeItem('222', 'v2', 'Model B');

  beforeEach(async () => {
    document.body.innerHTML = '';
    FakeWebSocket.instances = [];

    // Reset the shared mocks so mockResolvedValueOnce queues and call
    // history never leak between tests.
    mockApiClient.downloadModel.mockReset();
    mockApiClient.downloadHfModel.mockReset();
    mockApiClient.cancelDownload.mockReset();
    showToastMock.mockClear();
    showDownloadBatchSummaryMock.mockClear();
    resetAndReloadMock.mockClear();
    mockLoadingManager.hide.mockClear();
    mockLoadingManager.setStatus.mockClear();
    mockLoadingManager.showCancelButton.mockClear();
    mockLoadingManager.showDownloadProgress.mockClear();

    vi.stubGlobal('WebSocket', FakeWebSocket);
    vi.resetModules();
    ({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
    manager = new DownloadManager();
    // The constructor leaves apiClient null; executeBatchDownload reads it
    // directly, so point it at the shared mocked client.
    manager.apiClient = mockApiClient;
  });

  afterEach(() => {
    document.body.innerHTML = '';
    vi.unstubAllGlobals();
  });

  it('shows the success toast when every item downloads successfully', async () => {
    mockApiClient.downloadModel.mockResolvedValue({ success: true });

    await manager.executeBatchDownload([item0, item1], options);

    expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
    // Each item is downloaded with its own modelId + versionId.
    expect(mockApiClient.downloadModel.mock.calls[0][0]).toBe('111');
    expect(mockApiClient.downloadModel.mock.calls[0][1]).toBe('v1');
    expect(mockApiClient.downloadModel.mock.calls[1][0]).toBe('222');
    expect(mockApiClient.downloadModel.mock.calls[1][1]).toBe('v2');

    expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
    expect(showToastMock).toHaveBeenCalledTimes(1);
    expect(showToastMock).toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', { count: 2 }, 'success');
    expect(resetAndReloadMock).toHaveBeenCalledWith(true);
  });

  it('shows a partial-failure summary when some items fail', async () => {
    // The failing item has no displayName/filename, so the resolved entry
    // name falls back to the selected version name.
    const unnamedItem = { modelId: '333', selectedVersion: { id: 'v3', name: 'V3', existsLocally: false } };
    mockApiClient.downloadModel
      .mockResolvedValueOnce({ success: false, error: 'rate limited' })
      .mockResolvedValueOnce({ success: true });

    await manager.executeBatchDownload([unnamedItem, item1], options);

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.total).toBe(2);
    expect(summary.completed).toBe(1);
    expect(summary.failedItems).toHaveLength(1);
    expect(summary.failedItems[0].item).toBe(unnamedItem);
    expect(summary.failedItems[0].error).toBe('rate limited');
    // The resolved display name is carried on the failed entry.
    expect(summary.failedItems[0].name).toBe('V3');
    expect(summary.onRetry).toEqual(expect.any(Function));

    // No success toast and no downloadPartialSuccess toast for this path.
    expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), 'success');
    expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadPartialSuccess', expect.anything(), expect.anything());
  });

  it('shows an all-failed summary when every item fails', async () => {
    mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'x' });

    await manager.executeBatchDownload([item0, item1], options);

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.total).toBe(2);
    expect(summary.completed).toBe(0);
    expect(summary.failedItems).toHaveLength(2);
    expect(summary.failedItems[0].item).toBe(item0);
    expect(summary.failedItems[1].item).toBe(item1);
    expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), expect.anything());
  });

  it('records the error message when downloadModel rejects', async () => {
    // The item carries a filename but no displayName, so the resolved entry
    // name comes from the filename.
    const filenameItem = { modelId: '444', filename: 'model.safetensors', selectedVersion: { id: 'v4' } };
    mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));

    await manager.executeBatchDownload([filenameItem], options);

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.total).toBe(1);
    expect(summary.completed).toBe(0);
    expect(summary.failedItems).toHaveLength(1);
    expect(summary.failedItems[0].item).toBe(filenameItem);
    expect(summary.failedItems[0].error).toBe('network down');
    expect(summary.failedItems[0].name).toBe('model.safetensors');
  });

  it('retries the failed subset through onRetry with unwrapped items', async () => {
    mockApiClient.downloadModel
      .mockResolvedValueOnce({ success: false, error: 'rate limited' })
      .mockResolvedValueOnce({ success: true });

    await manager.executeBatchDownload([item0, item1], options);

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.failedItems).toHaveLength(1);

    // Retry the exact failed subset returned by the summary. The onRetry
    // callback unwraps the { item, error } entries back into raw model items
    // before re-running executeBatchDownload. Make the retried item fail
    // again so a second summary is produced.
    mockApiClient.downloadModel.mockResolvedValueOnce({ success: false, error: 'still rate limited' });
    await summary.onRetry(summary.failedItems);

    // downloadModel is called a third time — only for the failed item (item0),
    // NOT for the item that already succeeded (item1).
    expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(3);
    const retryCall = mockApiClient.downloadModel.mock.calls[2];
    expect(retryCall[0]).toBe(item0.modelId);
    expect(retryCall[1]).toBe(item0.selectedVersion.id);

    // A fresh summary is produced for the retry run (call count 1 -> 2).
    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(2);
    const retrySummary = showDownloadBatchSummaryMock.mock.calls[1][0];
    expect(retrySummary.total).toBe(1);
    expect(retrySummary.completed).toBe(0);
    expect(retrySummary.failedItems).toHaveLength(1);
    expect(retrySummary.failedItems[0].item).toBe(item0);
    expect(retrySummary.failedItems[0].error).toBe('still rate limited');
  });

  it('stops the batch without showing a summary when cancelled before downloads start', async () => {
    const downloadPromise = manager.executeBatchDownload([item0, item1], options);

    // showCancelButton captured the cancel callback synchronously. Invoking it
    // sets `cancelled = true` before the download loop runs (the loop only
    // starts after the WebSocket open promise resolves on the microtask queue).
    const cancelCallback = mockLoadingManager.showCancelButton.mock.calls[0][0];
    const cancelPromise = cancelCallback();

    await Promise.all([downloadPromise, cancelPromise]);

    expect(mockApiClient.downloadModel).not.toHaveBeenCalled();
    expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
    expect(showToastMock).toHaveBeenCalledWith(
      'toast.downloads.downloadStopped',
      expect.anything(),
      'info',
      expect.stringContaining('Download cancelled')
    );
    expect(resetAndReloadMock).toHaveBeenCalledWith(true);
  });

  it('shows the batch summary for a single CivitAI download resolved as a failure', async () => {
    mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'rate limited' });

    await manager.executeDownloadWithProgress({
      modelId: '111',
      versionId: 'v1',
      versionName: 'V1',
      modelRoot: '/m',
      useDefaultPaths: true,
    });

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.total).toBe(1);
    expect(summary.completed).toBe(0);
    expect(summary.failedItems).toHaveLength(1);
    expect(summary.failedItems[0].item.modelId).toBe('111');
    expect(summary.failedItems[0].item.versionId).toBe('v1');
    expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
    expect(summary.failedItems[0].error).toBe('rate limited');
    expect(summary.failedItems[0].name).toBe('V1');
    expect(summary.onRetry).toEqual(expect.any(Function));
    expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
  });

  it('shows the batch summary when a single download throws', async () => {
    mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));

    await manager.executeDownloadWithProgress({
      modelId: '111',
      versionId: 'v1',
      versionName: 'V1',
      modelRoot: '/m',
      useDefaultPaths: true,
    });

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.total).toBe(1);
    expect(summary.completed).toBe(0);
    expect(summary.failedItems).toHaveLength(1);
    expect(summary.failedItems[0].error).toBe('network down');
    expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
    expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
  });

  it('keeps the success toast and skips the summary for a successful single download', async () => {
    mockApiClient.downloadModel.mockResolvedValue({ success: true });

    const result = await manager.executeDownloadWithProgress({
      modelId: '111',
      versionId: 'v1',
      versionName: 'V1',
      modelRoot: '/m',
      useDefaultPaths: true,
    });

    expect(result).toBe(true);
    expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
    expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
    expect(resetAndReloadMock).toHaveBeenCalledWith(true);
  });

  it('retries a failed single download through onRetry with the same params', async () => {
    mockApiClient.downloadModel
      .mockResolvedValueOnce({ success: false, error: 'rate limited' })
      .mockResolvedValueOnce({ success: true });

    await manager.executeDownloadWithProgress({
      modelId: '111',
      versionId: 'v1',
      versionName: 'V1',
      modelRoot: '/m',
      useDefaultPaths: true,
    });

    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.failedItems).toHaveLength(1);

    await summary.onRetry();

    expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
    const retryCall = mockApiClient.downloadModel.mock.calls[1];
    expect(retryCall[0]).toBe('111');
    expect(retryCall[1]).toBe('v1');
    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
  });

  it('shows a summary for HF partial failure and retries only the failed files', async () => {
    manager.hfRepoId = 'user/repo';
    manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors'];
    mockApiClient.downloadHfModel
      .mockResolvedValueOnce({ success: true })
      .mockResolvedValueOnce({ success: false, error: 'denied' });

    const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true });

    expect(result).toBe(false);
    expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
    const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
    expect(summary.total).toBe(2);
    expect(summary.completed).toBe(1);
    expect(summary.failedItems).toHaveLength(1);
    expect(summary.failedItems[0].name).toBe('b.safetensors');
    expect(summary.failedItems[0].item.url).toEqual(
      expect.stringContaining('huggingface.co/user/repo/blob/main/b.safetensors')
    );

    await summary.onRetry();

    expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3);
    expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors');
  });
});
