import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { authFilesApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import { useNotificationStore } from '@/stores'; import type { AuthFileItem } from '@/types'; import { formatFileSize } from '@/utils/format'; import { MAX_AUTH_FILE_SIZE } from '@/utils/constants'; import { downloadBlob } from '@/utils/download'; import { getTypeLabel, hasAuthFileStatusMessage, isRuntimeOnlyAuthFile, } from '@/features/authFiles/constants'; type DeleteAllOptions = { filter: string; problemOnly: boolean; onResetFilterToAll: () => void; onResetProblemOnly: () => void; }; export type UseAuthFilesDataResult = { files: AuthFileItem[]; selectedFiles: Set; selectionCount: number; loading: boolean; error: string; uploading: boolean; deleting: string | null; deletingAll: boolean; statusUpdating: Record; batchStatusUpdating: boolean; fileInputRef: RefObject; loadFiles: () => Promise; handleUploadClick: () => void; handleFileChange: (event: ChangeEvent) => Promise; handleDelete: (name: string) => void; handleDeleteAll: (options: DeleteAllOptions) => void; handleDownload: (name: string) => Promise; handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise; toggleSelect: (name: string) => void; selectAllVisible: (visibleFiles: AuthFileItem[]) => void; invertVisibleSelection: (visibleFiles: AuthFileItem[]) => void; deselectAll: () => void; batchDownload: (names: string[]) => Promise; batchSetStatus: (names: string[], enabled: boolean) => Promise; batchDelete: (names: string[]) => void; }; export type UseAuthFilesDataOptions = { refreshKeyStats: () => Promise; }; export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult { const { refreshKeyStats } = options; const { t } = useTranslation(); const { showNotification, showConfirmation } = useNotificationStore(); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(null); const [deletingAll, setDeletingAll] = useState(false); const [statusUpdating, setStatusUpdating] = useState>({}); const [batchStatusUpdating, setBatchStatusUpdating] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const fileInputRef = useRef(null); const batchStatusPendingRef = useRef(false); const selectionCount = selectedFiles.size; const toggleSelect = useCallback((name: string) => { setSelectedFiles((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; }); }, []); const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => { const nextSelected = visibleFiles .filter((file) => !isRuntimeOnlyAuthFile(file)) .map((file) => file.name); if (nextSelected.length === 0) return; setSelectedFiles((prev) => { const next = new Set(prev); nextSelected.forEach((name) => next.add(name)); return next; }); }, []); const invertVisibleSelection = useCallback((visibleFiles: AuthFileItem[]) => { const visibleNames = visibleFiles .filter((file) => !isRuntimeOnlyAuthFile(file)) .map((file) => file.name); if (visibleNames.length === 0) return; setSelectedFiles((prev) => { const next = new Set(prev); visibleNames.forEach((name) => { if (next.has(name)) { next.delete(name); } else { next.add(name); } }); return next; }); }, []); const deselectAll = useCallback(() => { setSelectedFiles(new Set()); }, []); const applyDeletedFiles = useCallback((names: string[]) => { const deletedNames = Array.from( new Set( names .map((name) => name.trim()) .filter(Boolean) ) ); if (deletedNames.length === 0) return; const deletedSet = new Set(deletedNames); setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name))); setSelectedFiles((prev) => { if (prev.size === 0) return prev; let changed = false; const next = new Set(); prev.forEach((name) => { if (deletedSet.has(name)) { changed = true; } else { next.add(name); } }); return changed ? next : prev; }); }, []); useEffect(() => { if (selectedFiles.size === 0) return; const existingNames = new Set(files.map((file) => file.name)); setSelectedFiles((prev) => { let changed = false; const next = new Set(); prev.forEach((name) => { if (existingNames.has(name)) { next.add(name); } else { changed = true; } }); return changed ? next : prev; }); }, [files, selectedFiles.size]); const loadFiles = useCallback(async () => { setLoading(true); setError(''); try { const data = await authFilesApi.list(); setFiles(data?.files || []); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed'); setError(errorMessage); } finally { setLoading(false); } }, [t]); const handleUploadClick = useCallback(() => { fileInputRef.current?.click(); }, []); const handleFileChange = useCallback( async (event: ChangeEvent) => { const fileList = event.target.files; if (!fileList || fileList.length === 0) return; const filesToUpload = Array.from(fileList); const validFiles: File[] = []; const invalidFiles: string[] = []; const oversizedFiles: string[] = []; filesToUpload.forEach((file) => { if (!file.name.endsWith('.json')) { invalidFiles.push(file.name); return; } if (file.size > MAX_AUTH_FILE_SIZE) { oversizedFiles.push(file.name); return; } validFiles.push(file); }); if (invalidFiles.length > 0) { showNotification(t('auth_files.upload_error_json'), 'error'); } if (oversizedFiles.length > 0) { showNotification( t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }), 'error' ); } if (validFiles.length === 0) { event.target.value = ''; return; } setUploading(true); try { const result = await authFilesApi.uploadFiles(validFiles); const successCount = result.uploaded; if (successCount > 0) { const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : ''; showNotification( `${t('auth_files.upload_success')}${suffix}`, result.failed.length ? 'warning' : 'success' ); await loadFiles(); await refreshKeyStats(); } if (result.failed.length > 0) { const details = result.failed .map((item) => `${item.name}: ${item.error}`) .join('; '); showNotification(`${t('notification.upload_failed')}: ${details}`, 'error'); } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error'); } finally { setUploading(false); event.target.value = ''; } }, [loadFiles, refreshKeyStats, showNotification, t] ); const handleDelete = useCallback( (name: string) => { showConfirmation({ title: t('auth_files.delete_title', { defaultValue: 'Delete File' }), message: `${t('auth_files.delete_confirm')} "${name}" ?`, variant: 'danger', confirmText: t('common.confirm'), onConfirm: async () => { setDeleting(name); try { const result = await authFilesApi.deleteFile(name); showNotification(t('auth_files.delete_success'), 'success'); applyDeletedFiles(result.files.length > 0 ? result.files : [name]); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); } finally { setDeleting(null); } }, }); }, [applyDeletedFiles, showConfirmation, showNotification, t] ); const handleDeleteAll = useCallback( (deleteAllOptions: DeleteAllOptions) => { const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions; const isFiltered = filter !== 'all'; const isProblemOnly = problemOnly === true; const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all'); const confirmMessage = isProblemOnly ? isFiltered ? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel }) : t('auth_files.delete_problem_confirm') : isFiltered ? t('auth_files.delete_filtered_confirm', { type: typeLabel }) : t('auth_files.delete_all_confirm'); showConfirmation({ title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }), message: confirmMessage, variant: 'danger', confirmText: t('common.confirm'), onConfirm: async () => { setDeletingAll(true); try { if (!isFiltered && !isProblemOnly) { await authFilesApi.deleteAll(); showNotification(t('auth_files.delete_all_success'), 'success'); setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file))); deselectAll(); } else { const filesToDelete = files.filter((file) => { if (isRuntimeOnlyAuthFile(file)) return false; if (isFiltered && file.type !== filter) return false; if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false; return true; }); if (filesToDelete.length === 0) { const emptyMessage = isProblemOnly ? isFiltered ? t('auth_files.delete_problem_filtered_none', { type: typeLabel }) : t('auth_files.delete_problem_none') : t('auth_files.delete_filtered_none', { type: typeLabel }); showNotification(emptyMessage, 'info'); setDeletingAll(false); return; } const result = await authFilesApi.deleteFiles( filesToDelete.map((file) => file.name) ); const success = result.deleted; const failed = result.failed.length; applyDeletedFiles(result.files); if (failed === 0 && isProblemOnly) { showNotification( isFiltered ? t('auth_files.delete_problem_filtered_success', { count: success, type: typeLabel, }) : t('auth_files.delete_problem_success', { count: success }), 'success' ); } else if (failed === 0) { showNotification( t('auth_files.delete_filtered_success', { count: success, type: typeLabel }), 'success' ); } else if (isProblemOnly) { showNotification( isFiltered ? t('auth_files.delete_problem_filtered_partial', { success, failed, type: typeLabel, }) : t('auth_files.delete_problem_partial', { success, failed }), 'warning' ); } else { showNotification( t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }), 'warning' ); } if (isFiltered) { onResetFilterToAll(); } if (isProblemOnly) { onResetProblemOnly(); } } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); } finally { setDeletingAll(false); } }, }); }, [applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t] ); const handleDownload = useCallback( async (name: string) => { try { const response = await apiClient.getRaw( `/auth-files/download?name=${encodeURIComponent(name)}`, { responseType: 'blob' } ); const blob = new Blob([response.data]); downloadBlob({ filename: name, blob }); showNotification(t('auth_files.download_success'), 'success'); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error'); } }, [showNotification, t] ); const handleStatusToggle = useCallback( async (item: AuthFileItem, enabled: boolean) => { const name = item.name; const nextDisabled = !enabled; const previousDisabled = item.disabled === true; setStatusUpdating((prev) => ({ ...prev, [name]: true })); setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f))); try { const res = await authFilesApi.setStatus(name, nextDisabled); setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f)) ); showNotification( enabled ? t('auth_files.status_enabled_success', { name }) : t('auth_files.status_disabled_success', { name }), 'success' ); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f)) ); showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error'); } finally { setStatusUpdating((prev) => { if (!prev[name]) return prev; const next = { ...prev }; delete next[name]; return next; }); } }, [showNotification, t] ); const batchSetStatus = useCallback( async (names: string[], enabled: boolean) => { if (batchStatusPendingRef.current) return; const uniqueNames = Array.from(new Set(names)); if (uniqueNames.length === 0) return; if (uniqueNames.some((name) => statusUpdating[name] === true)) return; const originalDisabled = new Map( files .filter((file) => uniqueNames.includes(file.name)) .map((file) => [file.name, file.disabled === true]) ); const targetNames = new Set(originalDisabled.keys()); const targetNameList = Array.from(targetNames); if (targetNameList.length === 0) return; const nextDisabled = !enabled; batchStatusPendingRef.current = true; setBatchStatusUpdating(true); setStatusUpdating((prev) => { const next = { ...prev }; targetNameList.forEach((name) => { next[name] = true; }); return next; }); setFiles((prev) => prev.map((file) => targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file ) ); try { const results = await Promise.allSettled( targetNameList.map((name) => authFilesApi.setStatus(name, nextDisabled)) ); let successCount = 0; let failCount = 0; const failedNames = new Set(); const confirmedDisabled = new Map(); results.forEach((result, index) => { const name = targetNameList[index]; if (result.status === 'fulfilled') { successCount++; confirmedDisabled.set(name, result.value.disabled); } else { failCount++; failedNames.add(name); } }); setFiles((prev) => prev.map((file) => { if (failedNames.has(file.name)) { return { ...file, disabled: originalDisabled.get(file.name) === true }; } if (confirmedDisabled.has(file.name)) { return { ...file, disabled: confirmedDisabled.get(file.name) }; } return file; }) ); if (failCount === 0) { showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success'); } else { showNotification( t('auth_files.batch_status_partial', { success: successCount, failed: failCount }), 'warning' ); } deselectAll(); } finally { batchStatusPendingRef.current = false; setBatchStatusUpdating(false); setStatusUpdating((prev) => { const next = { ...prev }; targetNameList.forEach((name) => { delete next[name]; }); return next; }); } }, [deselectAll, files, showNotification, statusUpdating, t] ); const batchDownload = useCallback( async (names: string[]) => { const uniqueNames = Array.from(new Set(names)); if (uniqueNames.length === 0) return; let successCount = 0; let failCount = 0; for (const name of uniqueNames) { try { const response = await apiClient.getRaw( `/auth-files/download?name=${encodeURIComponent(name)}`, { responseType: 'blob' } ); const blob = new Blob([response.data]); downloadBlob({ filename: name, blob }); successCount++; } catch { failCount++; } } if (failCount === 0) { showNotification( t('auth_files.batch_download_success', { count: successCount }), 'success' ); } else { showNotification( t('auth_files.batch_download_partial', { success: successCount, failed: failCount }), 'warning' ); } }, [showNotification, t] ); const batchDelete = useCallback( (names: string[]) => { const uniqueNames = Array.from(new Set(names)); if (uniqueNames.length === 0) return; showConfirmation({ title: t('auth_files.batch_delete_title'), message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }), variant: 'danger', confirmText: t('common.confirm'), onConfirm: async () => { try { const result = await authFilesApi.deleteFiles(uniqueNames); applyDeletedFiles(result.files); if (result.failed.length === 0) { showNotification( `${t('auth_files.delete_all_success')} (${result.deleted})`, 'success' ); } else { showNotification( t('auth_files.delete_filtered_partial', { success: result.deleted, failed: result.failed.length, type: t('auth_files.filter_all'), }), 'warning' ); } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); } }, }); }, [applyDeletedFiles, showConfirmation, showNotification, t] ); return { files, selectedFiles, selectionCount, loading, error, uploading, deleting, deletingAll, statusUpdating, batchStatusUpdating, fileInputRef, loadFiles, handleUploadClick, handleFileChange, handleDelete, handleDeleteAll, handleDownload, handleStatusToggle, toggleSelect, selectAllVisible, invertVisibleSelection, deselectAll, batchDownload, batchSetStatus, batchDelete, }; }