using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; namespace AibisDream.Kit { internal sealed class FileLogger { private readonly ConcurrentQueue _queue = new(); private readonly AutoResetEvent _wakeEvent = new(false); private readonly ManualResetEventSlim _drainedEvent = new(true); private readonly List _batch = new(); private readonly string _directory; private readonly LogSettings _settings; private readonly Thread _worker; private readonly object _writerLock = new(); private volatile bool _isRunning = true; private StreamWriter _writer; private string _currentFilePath; private long _currentFileBytes; private int _queuedCount; private int _droppedLowPriorityCount; public string CurrentFilePath => _currentFilePath; public FileLogger(string directory, LogSettings settings) { _directory = directory; _settings = settings ?? LogSettings.CreateDefault(); Directory.CreateDirectory(_directory); DeleteOldFiles(); OpenNewFile(); _worker = new Thread(WriterLoop) { IsBackground = true, Name = "AIBIS File Logger" }; _worker.Start(); } public void Write(string content) { Write(LogEntry.System(LogLevel.Info, LogCategory.General, content)); } public void Write(LogEntry entry) { if (!_isRunning || !_settings.ShouldWrite(entry.Level)) return; if (TryEnqueue(entry)) { if (entry.Level >= LogLevel.Error) _wakeEvent.Set(); return; } if (entry.Level < LogLevel.Error) Interlocked.Increment(ref _droppedLowPriorityCount); } public void Flush(TimeSpan timeout) { _wakeEvent.Set(); _drainedEvent.Wait(timeout); lock (_writerLock) { _writer?.Flush(); } } public void OnDestroy() { _isRunning = false; _wakeEvent.Set(); if (!_worker.Join(_settings.ShutdownDrainMilliseconds)) Flush(TimeSpan.FromMilliseconds(_settings.ShutdownDrainMilliseconds)); lock (_writerLock) { _writer?.Flush(); _writer?.Dispose(); _writer = null; } if (!_worker.IsAlive) { _wakeEvent.Dispose(); _drainedEvent.Dispose(); } } private bool TryEnqueue(LogEntry entry) { while (true) { var current = Volatile.Read(ref _queuedCount); if (current >= _settings.QueueCapacity && entry.Level < LogLevel.Error) return false; if (Interlocked.CompareExchange(ref _queuedCount, current + 1, current) == current) break; } _drainedEvent.Reset(); _queue.Enqueue(entry); _wakeEvent.Set(); return true; } private void WriterLoop() { while (_isRunning || !_queue.IsEmpty) { _wakeEvent.WaitOne(_settings.FlushIntervalMilliseconds); DrainQueue(); } DrainQueue(); } private void DrainQueue() { _batch.Clear(); while (_batch.Count < _settings.BatchSize && _queue.TryDequeue(out var entry)) { Interlocked.Decrement(ref _queuedCount); _batch.Add(entry); } var dropped = Interlocked.Exchange(ref _droppedLowPriorityCount, 0); if (dropped > 0) { _batch.Add(LogEntry.System( LogLevel.Warning, LogCategory.General, $"Dropped {dropped} low-priority log entries because the queue was full.")); } if (_batch.Count == 0) { if (_queue.IsEmpty) _drainedEvent.Set(); return; } lock (_writerLock) { foreach (var entry in _batch) { WriteEntry(entry); } _writer?.Flush(); } if (!_queue.IsEmpty) _wakeEvent.Set(); if (_queue.IsEmpty) _drainedEvent.Set(); } private void WriteEntry(LogEntry entry) { try { var line = LogFormatter.Format(entry); var byteCount = Encoding.UTF8.GetByteCount(line) + Environment.NewLine.Length; if (_currentFileBytes + byteCount > _settings.MaxFileBytes) OpenNewFile(); _writer.WriteLine(line); _currentFileBytes += byteCount; } catch { _isRunning = false; } } private void OpenNewFile() { _writer?.Flush(); _writer?.Dispose(); var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var platform = _settings.Platform; var sessionId = _settings.SessionId; var baseFileName = $"{timestamp}_{platform}_v{_settings.AppVersion}_session-{sessionId}"; _currentFilePath = BuildUniquePath(baseFileName); _writer = new StreamWriter(new FileStream(_currentFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read), Encoding.UTF8) { AutoFlush = false }; _currentFileBytes = 0; DeleteOldFiles(); } private void DeleteOldFiles() { var directory = new DirectoryInfo(_directory); if (!directory.Exists) return; var files = directory.GetFiles("*.log"); Array.Sort(files, (a, b) => b.LastWriteTimeUtc.CompareTo(a.LastWriteTimeUtc)); long totalBytes = 0; for (var i = 0; i < files.Length; i++) { totalBytes += files[i].Length; var shouldDelete = i >= _settings.MaxSessionFiles || totalBytes > _settings.MaxTotalBytes; if (!shouldDelete) continue; try { files[i].Delete(); } catch { // Best-effort cleanup only. } } } private static string SanitizeFileName(string fileName) { foreach (var invalid in Path.GetInvalidFileNameChars()) { fileName = fileName.Replace(invalid, '_'); } return fileName; } private string BuildUniquePath(string baseFileName) { var safeBase = SanitizeFileName(baseFileName); var path = Path.Combine(_directory, safeBase + ".log"); var index = 1; while (File.Exists(path)) { path = Path.Combine(_directory, $"{safeBase}_{index}.log"); index++; } return path; } } }