Source: utils/LogCycling.js

/**
 * Dedicated log cycling and archive management utilities
 * Handles automated log rotation, compression, and cleanup
 */

import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { promisify } from 'util';
import { LOG_DIR } from './LoggingConfig.js';

// Define log directory structure
const CURRENT_LOG_DIR = path.join(LOG_DIR, 'current');
const ARCHIVE_LOG_DIR = path.join(LOG_DIR, 'archive');

const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);

// Configuration constants
const MAX_ARCHIVED_RUNS = 3;
const MAX_LOG_AGE_HOURS = 24;
const MAX_LOG_SIZE_MB = 100;
const COMPRESSION_LEVEL = 6;

/**
 * Log cycling and management utilities
 */
export class LogCyclingManager {
    constructor(options = {}) {
        this.maxArchivedRuns = options.maxArchivedRuns || MAX_ARCHIVED_RUNS;
        this.maxLogAgeHours = options.maxLogAgeHours || MAX_LOG_AGE_HOURS;
        this.maxLogSizeMB = options.maxLogSizeMB || MAX_LOG_SIZE_MB;
        this.compressionLevel = options.compressionLevel || COMPRESSION_LEVEL;
        this.dryRun = options.dryRun || false;
    }

    /**
     * Get current date string for log file identification
     */
    getDateString() {
        return new Date().toISOString().split('T')[0];
    }

    /**
     * Get timestamp for archive naming
     */
    getTimestamp() {
        return new Date().toISOString().replace(/[:.]/g, '-');
    }

    /**
     * Get file stats safely
     */
    getFileStats(filePath) {
        try {
            return fs.statSync(filePath);
        } catch (error) {
            return null;
        }
    }

    /**
     * Check if a file should be archived based on age or size
     */
    shouldArchiveFile(filePath) {
        const stats = this.getFileStats(filePath);
        if (!stats) return false;

        const fileName = path.basename(filePath);
        const today = this.getDateString();
        
        // Always archive files not from today
        if (!fileName.includes(today)) {
            return true;
        }

        // Archive if file is too large
        const fileSizeMB = stats.size / (1024 * 1024);
        if (fileSizeMB > this.maxLogSizeMB) {
            return true;
        }

        // Archive if file is too old
        const fileAgeHours = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60);
        if (fileAgeHours > this.maxLogAgeHours) {
            return true;
        }

        return false;
    }

    /**
     * Get all log files that should be archived
     */
    getFilesToArchive() {
        try {
            const files = fs.readdirSync(CURRENT_LOG_DIR);
            const logFiles = files.filter(file => file.endsWith('.log'));
            
            return logFiles
                .map(file => path.join(CURRENT_LOG_DIR, file))
                .filter(filePath => this.shouldArchiveFile(filePath))
                .map(filePath => {
                    const stats = this.getFileStats(filePath);
                    return {
                        path: filePath,
                        name: path.basename(filePath),
                        size: stats ? stats.size : 0,
                        mtime: stats ? stats.mtime : new Date()
                    };
                })
                .sort((a, b) => a.mtime - b.mtime); // Oldest first
        } catch (error) {
            logger.error('Error getting files to archive:', error);
            return [];
        }
    }

    /**
     * Compress a file using gzip
     */
    async compressFile(sourceFile, targetFile) {
        try {
            const sourceBuffer = fs.readFileSync(sourceFile);
            const compressed = await gzip(sourceBuffer, { level: this.compressionLevel });
            
            if (!this.dryRun) {
                fs.writeFileSync(targetFile, compressed);
            }
            
            return {
                originalSize: sourceBuffer.length,
                compressedSize: compressed.length,
                compressionRatio: (1 - compressed.length / sourceBuffer.length) * 100
            };
        } catch (error) {
            throw new Error(`Failed to compress ${sourceFile}: ${error.message}`);
        }
    }

    /**
     * Decompress a gzipped file
     */
    async decompressFile(sourceFile, targetFile) {
        try {
            const compressedBuffer = fs.readFileSync(sourceFile);
            const decompressed = await gunzip(compressedBuffer);
            
            if (!this.dryRun) {
                fs.writeFileSync(targetFile, decompressed);
            }
            
            return {
                compressedSize: compressedBuffer.length,
                decompressedSize: decompressed.length
            };
        } catch (error) {
            throw new Error(`Failed to decompress ${sourceFile}: ${error.message}`);
        }
    }

    /**
     * Create new archive run directory
     */
    createArchiveRun() {
        try {
            const existingRuns = fs.readdirSync(ARCHIVE_LOG_DIR)
                .filter(dir => dir.startsWith('run-'))
                .map(dir => parseInt(dir.split('-')[1]))
                .filter(num => !isNaN(num))
                .sort((a, b) => b - a); // Descending order

            const newRunNumber = existingRuns.length > 0 ? existingRuns[0] + 1 : 1;
            const runDir = path.join(ARCHIVE_LOG_DIR, `run-${newRunNumber}`);
            
            if (!this.dryRun) {
                fs.mkdirSync(runDir, { recursive: true });
            }
            
            return {
                runNumber: newRunNumber,
                runDir: runDir,
                previousRuns: existingRuns
            };
        } catch (error) {
            throw new Error(`Failed to create archive run: ${error.message}`);
        }
    }

    /**
     * Archive files to a run directory
     */
    async archiveFiles(files, runDir) {
        const results = [];
        let totalOriginalSize = 0;
        let totalCompressedSize = 0;
        
        for (const file of files) {
            try {
                const compressedName = `${file.name}.gz`;
                const targetPath = path.join(runDir, compressedName);
                
                const compressionResult = await this.compressFile(file.path, targetPath);
                
                if (!this.dryRun) {
                    fs.unlinkSync(file.path); // Remove original
                }
                
                results.push({
                    originalFile: file.name,
                    archivedFile: compressedName,
                    originalSize: compressionResult.originalSize,
                    compressedSize: compressionResult.compressedSize,
                    compressionRatio: compressionResult.compressionRatio,
                    success: true
                });
                
                totalOriginalSize += compressionResult.originalSize;
                totalCompressedSize += compressionResult.compressedSize;
                
            } catch (error) {
                results.push({
                    originalFile: file.name,
                    error: error.message,
                    success: false
                });
            }
        }
        
        return {
            files: results,
            summary: {
                totalFiles: files.length,
                successfulFiles: results.filter(r => r.success).length,
                failedFiles: results.filter(r => !r.success).length,
                totalOriginalSize,
                totalCompressedSize,
                totalCompressionRatio: totalOriginalSize > 0 ? 
                    (1 - totalCompressedSize / totalOriginalSize) * 100 : 0
            }
        };
    }

    /**
     * Clean up old archive runs
     */
    cleanupOldRuns() {
        try {
            const runDirs = fs.readdirSync(ARCHIVE_LOG_DIR)
                .filter(dir => dir.startsWith('run-'))
                .map(dir => ({
                    name: dir,
                    number: parseInt(dir.split('-')[1]),
                    path: path.join(ARCHIVE_LOG_DIR, dir)
                }))
                .filter(run => !isNaN(run.number))
                .sort((a, b) => b.number - a.number); // Newest first

            const runsToDelete = runDirs.slice(this.maxArchivedRuns);
            const deletedRuns = [];
            
            for (const run of runsToDelete) {
                try {
                    const runStats = this.getDirectorySize(run.path);
                    
                    if (!this.dryRun) {
                        fs.rmSync(run.path, { recursive: true, force: true });
                    }
                    
                    deletedRuns.push({
                        runName: run.name,
                        runNumber: run.number,
                        filesCount: runStats.filesCount,
                        totalSize: runStats.totalSize,
                        success: true
                    });
                } catch (error) {
                    deletedRuns.push({
                        runName: run.name,
                        runNumber: run.number,
                        error: error.message,
                        success: false
                    });
                }
            }
            
            return {
                keptRuns: runDirs.slice(0, this.maxArchivedRuns).map(r => r.name),
                deletedRuns: deletedRuns,
                summary: {
                    totalRuns: runDirs.length,
                    keptRuns: Math.min(runDirs.length, this.maxArchivedRuns),
                    deletedRuns: deletedRuns.length,
                    successfulDeletions: deletedRuns.filter(r => r.success).length,
                    failedDeletions: deletedRuns.filter(r => !r.success).length
                }
            };
        } catch (error) {
            throw new Error(`Failed to cleanup old runs: ${error.message}`);
        }
    }

    /**
     * Get directory size and file count
     */
    getDirectorySize(dirPath) {
        try {
            let totalSize = 0;
            let filesCount = 0;
            
            const files = fs.readdirSync(dirPath, { withFileTypes: true });
            
            for (const file of files) {
                const filePath = path.join(dirPath, file.name);
                
                if (file.isFile()) {
                    const stats = fs.statSync(filePath);
                    totalSize += stats.size;
                    filesCount++;
                } else if (file.isDirectory()) {
                    const subDirStats = this.getDirectorySize(filePath);
                    totalSize += subDirStats.totalSize;
                    filesCount += subDirStats.filesCount;
                }
            }
            
            return { totalSize, filesCount };
        } catch (error) {
            return { totalSize: 0, filesCount: 0 };
        }
    }

    /**
     * Main cycling operation
     */
    async cycleOldLogs() {
        try {
            const startTime = Date.now();
            
            // Get files to archive
            const filesToArchive = this.getFilesToArchive();
            
            if (filesToArchive.length === 0) {
                return {
                    success: true,
                    message: 'No files need to be archived',
                    duration: Date.now() - startTime,
                    dryRun: this.dryRun
                };
            }

            // Create new archive run
            const archiveRun = this.createArchiveRun();
            
            // Archive files
            const archiveResult = await this.archiveFiles(filesToArchive, archiveRun.runDir);
            
            // Clean up old runs
            const cleanupResult = this.cleanupOldRuns();
            
            const endTime = Date.now();
            
            return {
                success: true,
                message: `Successfully cycled ${archiveResult.summary.successfulFiles} log files to run-${archiveRun.runNumber}`,
                duration: endTime - startTime,
                dryRun: this.dryRun,
                archiveRun: {
                    runNumber: archiveRun.runNumber,
                    runDir: archiveRun.runDir
                },
                archiveResult: archiveResult,
                cleanupResult: cleanupResult,
                summary: {
                    totalFilesProcessed: filesToArchive.length,
                    successfulArchives: archiveResult.summary.successfulFiles,
                    failedArchives: archiveResult.summary.failedFiles,
                    spaceSaved: archiveResult.summary.totalOriginalSize - archiveResult.summary.totalCompressedSize,
                    compressionRatio: archiveResult.summary.totalCompressionRatio,
                    runsDeleted: cleanupResult.summary.deletedRuns
                }
            };
            
        } catch (error) {
            return {
                success: false,
                message: `Failed to cycle logs: ${error.message}`,
                error: error.message,
                duration: Date.now() - Date.now(),
                dryRun: this.dryRun
            };
        }
    }

    /**
     * Get archive statistics
     */
    getArchiveStats() {
        try {
            const currentStats = this.getDirectorySize(CURRENT_LOG_DIR);
            const archiveStats = this.getDirectorySize(ARCHIVE_LOG_DIR);
            
            const runDirs = fs.readdirSync(ARCHIVE_LOG_DIR)
                .filter(dir => dir.startsWith('run-'))
                .map(dir => {
                    const runPath = path.join(ARCHIVE_LOG_DIR, dir);
                    const runStats = this.getDirectorySize(runPath);
                    return {
                        run: dir,
                        filesCount: runStats.filesCount,
                        totalSize: runStats.totalSize
                    };
                });

            return {
                current: {
                    directory: CURRENT_LOG_DIR,
                    filesCount: currentStats.filesCount,
                    totalSize: currentStats.totalSize,
                    totalSizeMB: (currentStats.totalSize / (1024 * 1024)).toFixed(2)
                },
                archive: {
                    directory: ARCHIVE_LOG_DIR,
                    totalFilesCount: archiveStats.filesCount,
                    totalSize: archiveStats.totalSize,
                    totalSizeMB: (archiveStats.totalSize / (1024 * 1024)).toFixed(2),
                    runsCount: runDirs.length,
                    runs: runDirs
                },
                configuration: {
                    maxArchivedRuns: this.maxArchivedRuns,
                    maxLogAgeHours: this.maxLogAgeHours,
                    maxLogSizeMB: this.maxLogSizeMB
                }
            };
        } catch (error) {
            return {
                error: error.message
            };
        }
    }

    /**
     * Restore logs from an archive run
     */
    async restoreFromArchive(runNumber, filePattern = '*') {
        try {
            const runDir = path.join(ARCHIVE_LOG_DIR, `run-${runNumber}`);
            
            if (!fs.existsSync(runDir)) {
                throw new Error(`Archive run ${runNumber} not found`);
            }
            
            const files = fs.readdirSync(runDir)
                .filter(file => file.endsWith('.gz'))
                .filter(file => filePattern === '*' || file.includes(filePattern));
            
            const restored = [];
            
            for (const file of files) {
                const sourcePath = path.join(runDir, file);
                const originalName = file.replace('.gz', '');
                const targetPath = path.join(CURRENT_LOG_DIR, `restored-${originalName}`);
                
                try {
                    const decompressResult = await this.decompressFile(sourcePath, targetPath);
                    restored.push({
                        file: originalName,
                        success: true,
                        size: decompressResult.decompressedSize,
                        path: targetPath
                    });
                } catch (error) {
                    restored.push({
                        file: originalName,
                        success: false,
                        error: error.message
                    });
                }
            }
            
            return {
                success: true,
                runNumber: runNumber,
                filesRestored: restored.filter(f => f.success).length,
                filesFailed: restored.filter(f => !f.success).length,
                files: restored
            };
            
        } catch (error) {
            return {
                success: false,
                error: error.message
            };
        }
    }
}

// Export a default instance for convenience
export const defaultLogCycling = new LogCyclingManager();

// Export utility functions
export const cycleLogsNow = () => defaultLogCycling.cycleOldLogs();
export const getArchiveStats = () => defaultLogCycling.getArchiveStats();
export const restoreFromArchive = (runNumber, pattern) => 
    defaultLogCycling.restoreFromArchive(runNumber, pattern);

export default LogCyclingManager;