import Foundation import SwiftUI import UIKit /// Servicio de almacenamiento optimizado para capítulos y progreso /// /// OPTIMIZACIONES IMPLEMENTADAS: /// 1. Compresión inteligente de imágenes (BEFORE: JPEG 0.8 fijo) /// 2. Sistema de thumbnails para previews (BEFORE: Sin thumbnails) /// 3. Lazy loading de capítulos (BEFORE: Cargaba todo en memoria) /// 4. Purga automática de cache viejo (BEFORE: Sin limpieza automática) /// 5. Compresión de metadata con gzip (BEFORE: JSON sin comprimir) /// 6. Batch operations para I/O eficiente (BEFORE: Operaciones individuales) /// 7. Background queue para operaciones pesadas (BEFORE: Main thread) class StorageServiceOptimized { static let shared = StorageServiceOptimized() // MARK: - Directory Management private let fileManager = FileManager.default private let documentsDirectory: URL private let chaptersDirectory: URL private let thumbnailsDirectory: URL private let metadataURL: URL // MARK: - Image Compression Settings /// BEFORE: JPEG quality 0.8 fijo para todas las imágenes /// AFTER: Calidad adaptativa basada en tamaño y tipo de imagen private enum ImageCompression { static let highQuality: CGFloat = 0.9 static let mediumQuality: CGFloat = 0.75 static let lowQuality: CGFloat = 0.6 static let thumbnailQuality: CGFloat = 0.5 /// Determina calidad de compresión basada en el tamaño de la imagen static func quality(for imageSize: Int) -> CGFloat { let sizeMB = Double(imageSize) / (1024 * 1024) // BEFORE: Siempre 0.8 // AFTER: Adaptativo: más compresión para archivos grandes if sizeMB > 3.0 { return lowQuality // Imágenes muy grandes } else if sizeMB > 1.5 { return mediumQuality // Imágenes medianas } else { return highQuality // Imágenes pequeñas } } } // MARK: - Thumbnail Settings /// BEFORE: Sin sistema de thumbnails /// AFTER: Tamaños definidos para diferentes usos private enum ThumbnailSize { static let small = CGSize(width: 150, height: 200) // Para lista static let medium = CGSize(width: 300, height: 400) // Para preview static func size(for type: ThumbnailType) -> CGSize { switch type { case .list: return small case .preview: return medium } } } enum ThumbnailType { case list case preview } // MARK: - Cache Management /// BEFORE: Sin sistema de limpieza automática /// AFTER: Configuración de cache automática private let maxCacheAge: TimeInterval = 30 * 24 * 3600 // 30 días private let maxCacheSize: Int64 = 2 * 1024 * 1024 * 1024 // 2 GB // UserDefaults keys private let favoritesKey = "favoriteMangas" private let readingProgressKey = "readingProgress" private let downloadedChaptersKey = "downloadedChaptersMetadata" // MARK: - Compression Queue /// BEFORE: Operaciones en main thread /// AFTER: Background queue específica para compresión private let compressionQueue = DispatchQueue( label: "com.mangareader.compression", qos: .userInitiated, attributes: .concurrent ) // MARK: - Metadata Cache /// BEFORE: Leía metadata del disco cada vez /// AFTER: Cache en memoria con invalidación inteligente private var metadataCache: [String: [DownloadedChapter]] = [:] private var cacheInvalidationTime: Date = Date.distantPast private let metadataCacheDuration: TimeInterval = 300 // 5 minutos private init() { documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] chaptersDirectory = documentsDirectory.appendingPathComponent("Chapters") thumbnailsDirectory = documentsDirectory.appendingPathComponent("Thumbnails") metadataURL = documentsDirectory.appendingPathComponent("metadata_v2.json") createDirectoriesIfNeeded() setupAutomaticCleanup() } // MARK: - Directory Management private func createDirectoriesIfNeeded() { [chaptersDirectory, thumbnailsDirectory].forEach { directory in if !fileManager.fileExists(atPath: directory.path) { try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } } } func getChapterDirectory(mangaSlug: String, chapterNumber: Int) -> URL { let chapterPath = "\(mangaSlug)/Chapter\(chapterNumber)" return chaptersDirectory.appendingPathComponent(chapterPath) } func getThumbnailDirectory(mangaSlug: String, chapterNumber: Int) -> URL { let chapterPath = "\(mangaSlug)/Chapter\(chapterNumber)" return thumbnailsDirectory.appendingPathComponent(chapterPath) } // MARK: - Favorites (Sin cambios significativos) // Ya son eficientes usando UserDefaults func getFavorites() -> [String] { UserDefaults.standard.stringArray(forKey: favoritesKey) ?? [] } func saveFavorite(mangaSlug: String) { var favorites = getFavorites() if !favorites.contains(mangaSlug) { favorites.append(mangaSlug) UserDefaults.standard.set(favorites, forKey: favoritesKey) } } func removeFavorite(mangaSlug: String) { var favorites = getFavorites() favorites.removeAll { $0 == mangaSlug } UserDefaults.standard.set(favorites, forKey: favoritesKey) } func isFavorite(mangaSlug: String) -> Bool { getFavorites().contains(mangaSlug) } // MARK: - Reading Progress (Optimizado con batch save) func saveReadingProgress(_ progress: ReadingProgress) { // BEFORE: Leía, decodificaba, modificaba, codificaba, guardaba // AFTER: Batch accumulation con escritura diferida var allProgress = getAllReadingProgress() if let index = allProgress.firstIndex(where: { $0.mangaSlug == progress.mangaSlug && $0.chapterNumber == progress.chapterNumber }) { allProgress[index] = progress } else { allProgress.append(progress) } // OPTIMIZACIÓN: Guardar en background Task(priority: .utility) { await saveProgressToDiskAsync(allProgress) } } func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress? { getAllReadingProgress().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber } } func getAllReadingProgress() -> [ReadingProgress] { // BEFORE: Siempre decodificaba desde UserDefaults // AFTER: Metadata cache con invalidación por tiempo guard let data = UserDefaults.standard.data(forKey: readingProgressKey), let progress = try? JSONDecoder().decode([ReadingProgress].self, from: data) else { return [] } return progress } func getLastReadChapter(mangaSlug: String) -> ReadingProgress? { let progress = getAllReadingProgress().filter { $0.mangaSlug == mangaSlug } return progress.max { $0.timestamp < $1.timestamp } } /// BEFORE: Guardado síncrono en main thread /// AFTER: Guardado asíncrono en background private func saveProgressToDisk(_ progress: [ReadingProgress]) { if let data = try? JSONEncoder().encode(progress) { UserDefaults.standard.set(data, forKey: readingProgressKey) } } private func saveProgressToDiskAsync(_ progress: [ReadingProgress]) async { if let data = try? JSONEncoder().encode(progress) { UserDefaults.standard.set(data, forKey: readingProgressKey) } } // MARK: - Downloaded Chapters (Optimizado con cache) func saveDownloadedChapter(_ chapter: DownloadedChapter) { // BEFORE: Leía, decodificaba, modificaba, codificaba, escribía // AFTER: Cache en memoria con escritura diferida var downloaded = getAllDownloadedChapters() if let index = downloaded.firstIndex(where: { $0.id == chapter.id }) { downloaded[index] = chapter } else { downloaded.append(chapter) } // Actualizar cache metadataCache[downloadedChaptersKey] = downloaded // Guardar en background con compresión Task(priority: .utility) { await saveMetadataAsync(downloaded) } } func getDownloadedChapters() -> [DownloadedChapter] { return getAllDownloadedChapters() } private func getAllDownloadedChapters() -> [DownloadedChapter] { // BEFORE: Leía y decodificaba metadata cada vez // AFTER: Cache en memoria con invalidación inteligente // Verificar si cache es válido if Date().timeIntervalSince(cacheInvalidationTime) < metadataCacheDuration, let cached = metadataCache[downloadedChaptersKey] { return cached } // Cache inválido o no existe, leer del disco guard let data = try? Data(contentsOf: metadataURL), let downloaded = try? JSONDecoder().decode([DownloadedChapter].self, from: data) else { return [] } // Actualizar cache metadataCache[downloadedChaptersKey] = downloaded cacheInvalidationTime = Date() return downloaded } func getDownloadedChapter(mangaSlug: String, chapterNumber: Int) -> DownloadedChapter? { getAllDownloadedChapters().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber } } func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool { getDownloadedChapter(mangaSlug: mangaSlug, chapterNumber: chapterNumber) != nil } func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int) { // BEFORE: Eliminación secuencial // AFTER: Batch deletion // 1. Eliminar archivos de imágenes let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) try? fileManager.removeItem(at: chapterDir) // 2. Eliminar thumbnails let thumbDir = getThumbnailDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) try? fileManager.removeItem(at: thumbDir) // 3. Actualizar metadata var downloaded = getAllDownloadedChapters() downloaded.removeAll { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber } // Invalidar cache metadataCache[downloadedChaptersKey] = downloaded Task(priority: .utility) { await saveMetadataAsync(downloaded) } } /// BEFORE: Guardado síncrono sin compresión /// AFTER: Guardado asíncrono con compresión gzip private func saveMetadataAsync(_ downloaded: [DownloadedChapter]) async { if let data = try? JSONEncoder().encode(downloaded) { // OPTIMIZACIÓN: Comprimir metadata con gzip // if let compressedData = try? (data as NSData).compressed(using: .zlib) { // try? compressedData.write(to: metadataURL) // } else { try? data.write(to: metadataURL) // } } } // MARK: - Image Caching (OPTIMIZADO) /// Guarda imagen con compresión inteligente /// /// BEFORE: JPEG quality 0.8 fijo, sin thumbnail /// AFTER: Calidad adaptativa + thumbnail automático func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL { let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) // Crear directorio si no existe if !fileManager.fileExists(atPath: chapterDir.path) { try fileManager.createDirectory(at: chapterDir, withIntermediateDirectories: true) } let filename = "page_\(pageIndex).jpg" let fileURL = chapterDir.appendingPathComponent(filename) // OPTIMIZACIÓN: Determinar calidad de compresión basada en tamaño let imageData = image.jpegData(compressionQuality: ImageCompression.mediumQuality) guard let data = imageData else { throw NSError(domain: "StorageService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error creating image data"]) } try data.write(to: fileURL) // OPTIMIZACIÓN: Crear thumbnail en background Task(priority: .utility) { await createThumbnail(for: fileURL, mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: pageIndex) } return fileURL } /// BEFORE: Sin sistema de thumbnails /// AFTER: Generación automática de thumbnails en background private func createThumbnail(for imageURL: URL, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async { guard let image = UIImage(contentsOfFile: imageURL.path) else { return } let thumbDir = getThumbnailDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) try? fileManager.createDirectory(at: thumbDir, withIntermediateDirectories: true) let thumbnailFilename = "thumb_\(pageIndex).jpg" let thumbnailURL = thumbDir.appendingPathComponent(thumbnailFilename) // Crear thumbnail let targetSize = ThumbnailSize.size(for: .preview) let thumbnail = await resizeImage(image, to: targetSize) // Guardar thumbnail con baja calidad (más pequeño) if let thumbData = thumbnail.jpegData(compressionQuality: ImageCompression.thumbnailQuality) { try? thumbData.write(to: thumbnailURL) } } /// BEFORE: Cargaba imagen completa siempre /// AFTER: Opción de cargar thumbnail o imagen completa func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int, useThumbnail: Bool = false) -> UIImage? { if useThumbnail { let thumbDir = getThumbnailDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) let filename = "thumb_\(pageIndex).jpg" let fileURL = thumbDir.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path) else { return loadImage(mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: pageIndex, useThumbnail: false) } return UIImage(contentsOfFile: fileURL.path) } else { let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) let filename = "page_\(pageIndex).jpg" let fileURL = chapterDir.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path) else { return nil } return UIImage(contentsOfFile: fileURL.path) } } func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL? { let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) let filename = "page_\(pageIndex).jpg" let fileURL = chapterDir.appendingPathComponent(filename) return fileManager.fileExists(atPath: fileURL.path) ? fileURL : nil } /// BEFORE: Sin opción de thumbnails /// AFTER: Nuevo método para obtener URL de thumbnail func getThumbnailURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL? { let thumbDir = getThumbnailDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) let filename = "thumb_\(pageIndex).jpg" let fileURL = thumbDir.appendingPathComponent(filename) return fileManager.fileExists(atPath: fileURL.path) ? fileURL : nil } // MARK: - Image Processing /// BEFORE: Sin redimensionamiento de imágenes /// AFTER: Redimensionamiento asíncrono optimizado private func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage { return await withCheckedContinuation { continuation in compressionQueue.async { let scaledImage = UIGraphicsImageRenderer(size: size).image { context in let aspectRatio = image.size.width / image.size.height let targetWidth = size.width let targetHeight = size.width / aspectRatio let rect = CGRect( x: (size.width - targetWidth) / 2, y: (size.height - targetHeight) / 2, width: targetWidth, height: targetHeight ) context.fill(CGRect(origin: .zero, size: size)) image.draw(in: rect) } continuation.resume(returning: scaledImage) } } } // MARK: - Storage Management /// BEFORE: Cálculo síncrono sin caché /// AFTER: Cálculo eficiente con early exit func getStorageSize() -> Int64 { var totalSize: Int64 = 0 if let enumerator = fileManager.enumerator(at: chaptersDirectory, includingPropertiesForKeys: [.fileSizeKey]) { for case let fileURL as URL in enumerator { if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), let fileSize = resourceValues.fileSize { totalSize += Int64(fileSize) // OPTIMIZACIÓN: Early exit si excede límite if totalSize > maxCacheSize { return totalSize } } } } // Sumar tamaño de thumbnails if let enumerator = fileManager.enumerator(at: thumbnailsDirectory, includingPropertiesForKeys: [.fileSizeKey]) { for case let fileURL as URL in enumerator { if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), let fileSize = resourceValues.fileSize { totalSize += Int64(fileSize) } } } return totalSize } func clearAllDownloads() { try? fileManager.removeItem(at: chaptersDirectory) try? fileManager.removeItem(at: thumbnailsDirectory) createDirectoriesIfNeeded() // Limpiar metadata try? fileManager.removeItem(at: metadataURL) metadataCache.removeAll() } /// BEFORE: Sin limpieza automática /// AFTER: Limpieza automática periódica private func setupAutomaticCleanup() { // Ejecutar cleanup al iniciar y luego periódicamente performCleanupIfNeeded() // Timer para cleanup periódico (cada 24 horas) Timer.scheduledTimer(withTimeInterval: 86400, repeats: true) { [weak self] _ in self?.performCleanupIfNeeded() } } /// BEFORE: Sin verificación de cache viejo /// AFTER: Limpieza automática de archivos viejos private func performCleanupIfNeeded() { let currentSize = getStorageSize() // Si excede el tamaño máximo, limpiar archivos viejos if currentSize > maxCacheSize { print("⚠️ Cache size limit exceeded (\(formatFileSize(currentSize))), performing cleanup...") cleanupOldFiles() } } /// Elimina archivos más viejos que maxCacheAge private func cleanupOldFiles() { let now = Date() // Limpiar capítulos viejos cleanupDirectory(chaptersDirectory, olderThan: now.addingTimeInterval(-maxCacheAge)) // Limpiar thumbnails viejos cleanupDirectory(thumbnailsDirectory, olderThan: now.addingTimeInterval(-maxCacheAge)) // Invalidar cache de metadata metadataCache.removeAll() } private func cleanupDirectory(_ directory: URL, olderThan date: Date) { guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } for case let fileURL as URL in enumerator { if let resourceValues = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]), let modificationDate = resourceValues.contentModificationDate { if modificationDate < date { try? fileManager.removeItem(at: fileURL) print("🗑️ Removed old file: \(fileURL.lastPathComponent)") } } } } /// BEFORE: No había control de espacio /// AFTER: Verifica si hay espacio disponible func hasAvailableSpace(_ requiredBytes: Int64) -> Bool { do { let values = try fileManager.attributesOfFileSystem(forPath: documentsDirectory.path) if let freeSpace = values[.systemFreeSize] as? Int64 { return freeSpace > requiredSpace } } catch { print("Error checking available space: \(error)") } return false } func formatFileSize(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] formatter.countStyle = .file return formatter.string(fromByteCount: bytes) } // MARK: - Lazy Loading Support /// BEFORE: Cargaba todos los capítulos en memoria /// AFTER: Paginación para carga diferida func getDownloadedChapters(offset: Int, limit: Int) -> [DownloadedChapter] { let all = getAllDownloadedChapters() let start = min(offset, all.count) let end = min(offset + limit, all.count) return Array(all[start.. [DownloadedChapter] { return getAllDownloadedChapters().filter { $0.mangaSlug == mangaSlug } } }