ModernJsonMaterialRepository.java

package com.university.bookstore.repository;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.university.bookstore.model.Material;

/**
 * Modern JSON-based implementation of MaterialRepository using best practices.
 * Features:
 * - Try-with-resources for automatic resource management
 * - NIO.2 for better file operations
 * - Thread-safe file operations with ReadWriteLock
 * - Better error handling and logging
 * - Atomic file operations to prevent corruption
 * 
 * @author Navid Mohaghegh
 * @version 4.0
 * @since 2024-09-15
 */
public class ModernJsonMaterialRepository implements MaterialRepository, AutoCloseable {
    
    private static final Logger LOGGER = Logger.getLogger(ModernJsonMaterialRepository.class.getName());
    private static final String SAFE_BASE_DIR = System.getProperty("user.dir") + "/data";
    private static final int MAX_PATH_LENGTH = 255;
    
    private final Path dataFile;
    private final Path backupFile;
    private final ObjectMapper objectMapper;
    private final ReadWriteLock fileLock;
    private volatile boolean closed = false;
    
    /**
     * Creates a new modern JSON material repository.
     * 
     * @param filePath the path to the JSON file for persistence
     */
    public ModernJsonMaterialRepository(String filePath) {
        Path validatedPath = validateAndSanitizePath(filePath);
        this.dataFile = validatedPath;
        this.backupFile = Paths.get(validatedPath.toString() + ".backup");
        this.fileLock = new ReentrantReadWriteLock();
        
        this.objectMapper = new ObjectMapper();
        this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
        this.objectMapper.findAndRegisterModules(); // For Java 8 time support
        
        initializeStorage();
    }
    
    private void initializeStorage() {
        try {
            // Ensure the directory exists
            Path parentDir = dataFile.getParent();
            if (parentDir != null && !Files.exists(parentDir)) {
                Files.createDirectories(parentDir);
                LOGGER.info("Created directory: " + parentDir);
            }
            
            // Create empty file if it doesn't exist
            if (!Files.exists(dataFile)) {
                saveAtomic(new ArrayList<>());
                LOGGER.info("Created new data file: " + dataFile);
            }
        } catch (IOException e) {
            throw new RepositoryException("Failed to initialize storage", e);
        }
    }
    
    /**
     * Validates and sanitizes the file path to prevent path traversal attacks.
     * 
     * @param filePath the file path to validate
     * @return the validated and sanitized Path
     * @throws SecurityException if the path is invalid or attempts path traversal
     */
    private Path validateAndSanitizePath(String filePath) {
        if (filePath == null || filePath.trim().isEmpty()) {
            throw new IllegalArgumentException("File path cannot be null or empty");
        }
        
        // Remove any path traversal attempts
        String cleanPath = filePath.replaceAll("\\.\\./", "").replaceAll("\\.\\.", "");
        
        // Check for suspicious patterns
        if (cleanPath.contains("../") || cleanPath.contains("..\\") || 
            cleanPath.contains("%2e%2e") || cleanPath.contains("%252e")) {
            throw new SecurityException("Invalid file path: potential path traversal detected");
        }
        
        // Validate path length
        if (cleanPath.length() > MAX_PATH_LENGTH) {
            throw new IllegalArgumentException("File path exceeds maximum length");
        }
        
        try {
            // Normalize the path
            Path normalizedPath = Paths.get(cleanPath).normalize();
            Path safePath = Paths.get(SAFE_BASE_DIR).normalize();
            
            // If the path is not absolute, make it relative to safe directory
            if (!normalizedPath.isAbsolute()) {
                normalizedPath = safePath.resolve(normalizedPath).normalize();
            }
            
            // Ensure the normalized path is within the safe directory
            if (!normalizedPath.startsWith(safePath)) {
                throw new SecurityException("File path must be within the safe directory: " + SAFE_BASE_DIR);
            }
            
            return normalizedPath;
        } catch (Exception e) {
            if (e instanceof SecurityException) {
                throw (SecurityException) e;
            }
            throw new IllegalArgumentException("Invalid file path: " + e.getMessage(), e);
        }
    }
    
    @Override
    public void save(Material material) {
        if (material == null) {
            throw new IllegalArgumentException("Material cannot be null");
        }
        ensureNotClosed();
        
        fileLock.writeLock().lock();
        try {
            List<Material> materials = loadAllInternal();
            
            // Remove existing material with same ID if it exists
            materials.removeIf(m -> m.getId().equals(material.getId()));
            
            // Add the new/updated material
            materials.add(material);
            
            // Save atomically using wrapper
            saveAtomic(materials);
            
            LOGGER.fine("Saved material: " + material.getId());
        } catch (IOException e) {
            throw new RepositoryException("Failed to save material: " + material.getId(), e);
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    /**
     * Saves multiple materials in batch for better performance.
     * 
     * @param materialsToSave collection of materials to save
     */
    public void saveAll(Collection<Material> materialsToSave) {
        Objects.requireNonNull(materialsToSave, "Materials collection cannot be null");
        ensureNotClosed();
        
        if (materialsToSave.isEmpty()) {
            return;
        }
        
        fileLock.writeLock().lock();
        try {
            List<Material> materials = loadAllInternal();
            
            // Create a map for efficient lookup
            Map<String, Material> materialMap = new HashMap<>();
            for (Material m : materials) {
                materialMap.put(m.getId(), m);
            }
            
            // Update or add new materials
            for (Material material : materialsToSave) {
                if (material != null) {
                    materialMap.put(material.getId(), material);
                }
            }
            
            // Save atomically
            saveAtomic(new ArrayList<>(materialMap.values()));
            
            LOGGER.fine("Saved " + materialsToSave.size() + " materials in batch");
        } catch (IOException e) {
            throw new RepositoryException("Failed to save materials batch", e);
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    @Override
    public Optional<Material> findById(String id) {
        if (id == null || id.trim().isEmpty()) {
            return Optional.empty();
        }
        ensureNotClosed();
        
        fileLock.readLock().lock();
        try {
            List<Material> materials = loadAllInternal();
            return materials.stream()
                .filter(m -> id.equals(m.getId()))
                .findFirst();
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "Failed to find material by ID: " + id, e);
            return Optional.empty();
        } finally {
            fileLock.readLock().unlock();
        }
    }
    
    @Override
    public List<Material> findAll() {
        ensureNotClosed();
        
        fileLock.readLock().lock();
        try {
            return new ArrayList<>(loadAllInternal());
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "Failed to load all materials", e);
            return new ArrayList<>();
        } finally {
            fileLock.readLock().unlock();
        }
    }
    
    @Override
    public boolean delete(String id) {
        if (id == null || id.trim().isEmpty()) {
            return false;
        }
        ensureNotClosed();
        
        fileLock.writeLock().lock();
        try {
            List<Material> materials = loadAllInternal();
            boolean removed = materials.removeIf(m -> id.equals(m.getId()));
            
            if (removed) {
                saveAtomic(materials);
                LOGGER.fine("Deleted material: " + id);
            }
            
            return removed;
        } catch (IOException e) {
            throw new RepositoryException("Failed to delete material: " + id, e);
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    /**
     * Deletes multiple materials in batch.
     * 
     * @param ids collection of IDs to delete
     * @return number of materials deleted
     */
    public int deleteAll(Collection<String> ids) {
        if (ids == null || ids.isEmpty()) {
            return 0;
        }
        ensureNotClosed();
        
        fileLock.writeLock().lock();
        try {
            List<Material> materials = loadAllInternal();
            Set<String> idSet = new HashSet<>(ids);
            int originalSize = materials.size();
            
            materials.removeIf(m -> idSet.contains(m.getId()));
            int deleted = originalSize - materials.size();
            
            if (deleted > 0) {
                saveAtomic(materials);
                LOGGER.fine("Deleted " + deleted + " materials in batch");
            }
            
            return deleted;
        } catch (IOException e) {
            throw new RepositoryException("Failed to delete materials batch", e);
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    @Override
    public boolean exists(String id) {
        return findById(id).isPresent();
    }
    
    @Override
    public long count() {
        ensureNotClosed();
        
        fileLock.readLock().lock();
        try {
            return loadAllInternal().size();
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "Failed to count materials", e);
            return 0;
        } finally {
            fileLock.readLock().unlock();
        }
    }
    
    @Override
    public void deleteAll() {
        ensureNotClosed();
        
        fileLock.writeLock().lock();
        try {
            saveAtomic(new ArrayList<>());
            LOGGER.info("Cleared all materials from repository");
        } catch (IOException e) {
            throw new RepositoryException("Failed to clear all materials", e);
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    /**
     * Loads all materials from the JSON file using try-with-resources.
     * 
     * @return list of materials
     * @throws IOException if file reading fails
     */
    private List<Material> loadAllInternal() throws IOException {
        if (!Files.exists(dataFile) || Files.size(dataFile) == 0) {
            return new ArrayList<>();
        }
        
        // Use try-with-resources for automatic resource management
        try (BufferedReader reader = Files.newBufferedReader(dataFile)) {
            MaterialsWrapper wrapper = objectMapper.readValue(reader, MaterialsWrapper.class);
            return wrapper.getMaterials() != null ? wrapper.getMaterials() : new ArrayList<>();
        } catch (IOException e) {
            // Try to restore from backup if main file is corrupted
            if (Files.exists(backupFile)) {
                LOGGER.warning("Main file corrupted, attempting to restore from backup");
                try (BufferedReader backupReader = Files.newBufferedReader(backupFile)) {
                    MaterialsWrapper wrapper = objectMapper.readValue(backupReader, MaterialsWrapper.class);
                    // Restore the main file from backup
                    Files.copy(backupFile, dataFile, 
                        java.nio.file.StandardCopyOption.REPLACE_EXISTING);
                    return wrapper.getMaterials() != null ? wrapper.getMaterials() : new ArrayList<>();
                } catch (IOException backupError) {
                    LOGGER.log(Level.SEVERE, "Failed to restore from backup", backupError);
                    return new ArrayList<>();
                }
            }
            LOGGER.log(Level.WARNING, "Failed to parse JSON, returning empty list", e);
            return new ArrayList<>();
        }
    }
    
    /**
     * Saves materials atomically to prevent corruption.
     * 
     * @param materials list of materials to save
     * @throws IOException if saving fails
     */
    private void saveAtomic(List<Material> materials) throws IOException {
        // Create backup of current file if it exists
        if (Files.exists(dataFile)) {
            Files.copy(dataFile, backupFile, 
                java.nio.file.StandardCopyOption.REPLACE_EXISTING);
        }
        
        // Write to temporary file first
        Path tempFile = Files.createTempFile(dataFile.getParent(), "temp", ".json");
        
        try {
            // Use try-with-resources for writing with wrapper
            try (BufferedWriter writer = Files.newBufferedWriter(tempFile, 
                    StandardOpenOption.CREATE, 
                    StandardOpenOption.TRUNCATE_EXISTING)) {
                MaterialsWrapper wrapper = new MaterialsWrapper(materials);
                objectMapper.writerWithDefaultPrettyPrinter().writeValue(writer, wrapper);
            }
            
            // Atomic move (rename) - this is atomic on most file systems
            Files.move(tempFile, dataFile, 
                java.nio.file.StandardCopyOption.REPLACE_EXISTING,
                java.nio.file.StandardCopyOption.ATOMIC_MOVE);
            
        } catch (IOException e) {
            // Clean up temp file if operation failed
            try {
                Files.deleteIfExists(tempFile);
            } catch (IOException deleteError) {
                LOGGER.log(Level.WARNING, "Failed to delete temp file", deleteError);
            }
            throw e;
        }
    }
    
    /**
     * Creates a backup of the current data file.
     * 
     * @return true if backup was created successfully
     */
    public boolean createBackup() {
        ensureNotClosed();
        
        fileLock.readLock().lock();
        try {
            if (Files.exists(dataFile)) {
                Path backupPath = Paths.get(dataFile + "." + System.currentTimeMillis() + ".backup");
                Files.copy(dataFile, backupPath);
                LOGGER.info("Created backup: " + backupPath);
                return true;
            }
            return false;
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "Failed to create backup", e);
            return false;
        } finally {
            fileLock.readLock().unlock();
        }
    }
    
    /**
     * Restores data from a backup file.
     * 
     * @param backupPath path to the backup file
     * @return true if restore was successful
     */
    public boolean restoreFromBackup(String backupPath) {
        Objects.requireNonNull(backupPath, "Backup path cannot be null");
        ensureNotClosed();
        
        Path backup = Paths.get(backupPath);
        if (!Files.exists(backup)) {
            LOGGER.warning("Backup file does not exist: " + backupPath);
            return false;
        }
        
        fileLock.writeLock().lock();
        try {
            // Validate backup file can be read
            MaterialsWrapper wrapper;
            try (BufferedReader reader = Files.newBufferedReader(backup)) {
                wrapper = objectMapper.readValue(reader, MaterialsWrapper.class);
            }
            
            // If validation successful, replace current file
            Files.copy(backup, dataFile, 
                java.nio.file.StandardCopyOption.REPLACE_EXISTING);
            
            LOGGER.info("Restored from backup: " + backupPath);
            return true;
        } catch (IOException e) {
            LOGGER.log(Level.SEVERE, "Failed to restore from backup: " + e.getMessage(), e);
            e.printStackTrace();
            return false;
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    /**
     * Gets the file path used for persistence.
     * 
     * @return the file path
     */
    public String getFilePath() {
        return dataFile.toString();
    }
    
    /**
     * Checks if the data file exists.
     * 
     * @return true if the file exists
     */
    public boolean dataFileExists() {
        return Files.exists(dataFile);
    }
    
    /**
     * Gets the size of the data file in bytes.
     * 
     * @return file size in bytes
     */
    public long getDataFileSize() {
        try {
            return Files.size(dataFile);
        } catch (IOException e) {
            return 0;
        }
    }
    
    /**
     * Performs maintenance operations like cleanup and optimization.
     */
    public void performMaintenance() {
        ensureNotClosed();
        
        fileLock.writeLock().lock();
        try {
            // Clean up old backup files
            Path parent = dataFile.getParent();
            if (parent != null) {
                long cutoffTime = System.currentTimeMillis() - (7L * 24 * 60 * 60 * 1000); // 7 days
                
                Files.list(parent)
                    .filter(path -> path.toString().endsWith(".backup"))
                    .filter(path -> {
                        try {
                            return Files.getLastModifiedTime(path).toMillis() < cutoffTime;
                        } catch (IOException e) {
                            return false;
                        }
                    })
                    .forEach(path -> {
                        try {
                            Files.delete(path);
                            LOGGER.fine("Deleted old backup: " + path);
                        } catch (IOException e) {
                            LOGGER.warning("Failed to delete old backup: " + path);
                        }
                    });
            }
            
            // Compact the JSON file by rewriting it
            List<Material> materials = loadAllInternal();
            saveAtomic(materials);
            
            LOGGER.info("Maintenance completed");
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "Maintenance failed", e);
        } finally {
            fileLock.writeLock().unlock();
        }
    }
    
    private void ensureNotClosed() {
        if (closed) {
            throw new IllegalStateException("Repository has been closed");
        }
    }
    
    @Override
    public void close() {
        if (!closed) {
            closed = true;
            LOGGER.info("Repository closed");
        }
    }
}