using System.Globalization;
using System.Text.RegularExpressions;
namespace Campus_live_rewrite.api.dbs
{
///
/// LiteDB backup + restore for the admin "Database" tab.
/// • Snapshots every .db in Data/DBs three times a day (every 8h) into Data/Backups/backup_UTCstamp/
/// • Prunes snapshots older than 3 days.
/// • Restore is STAGED (a marker file) and applied at next startup BEFORE any DB is opened —
/// the only safe moment to overwrite the live files, which are otherwise held open by LiteDB.
/// The admin endpoint stages the restore then restarts the process (systemd Restart=always).
///
public static class BackupService
{
private static readonly string DbDir = Path.Combine("Data", "DBs");
private static readonly string BackupRoot = Path.Combine("Data", "Backups");
private static readonly string RestoreMarker = Path.Combine(BackupRoot, ".restore");
private const int RetentionDays = 3;
private const int IntervalHours = 8; // 3x/day
private static Timer? _timer;
// ── startup restore (call FIRST in Main, before any DB access) ──────────────────────
public static void RestorePendingIfAny()
{
try
{
if (!File.Exists(RestoreMarker)) return;
string name = SafeName(File.ReadAllText(RestoreMarker).Trim());
string dir = Path.Combine(BackupRoot, name);
if (name.Length > 0 && Directory.Exists(dir))
{
Directory.CreateDirectory(DbDir);
// Drop stale write-ahead logs so a restored .db is not overlaid by an old WAL.
foreach (var log in Directory.GetFiles(DbDir, "*-log.db"))
try { File.Delete(log); } catch { }
int n = 0;
foreach (var f in Directory.GetFiles(dir, "*.db"))
{
File.Copy(f, Path.Combine(DbDir, Path.GetFileName(f)), overwrite: true);
n++;
}
Console.WriteLine($"[backup] RESTORED {n} files from '{name}' before startup");
}
File.Delete(RestoreMarker);
}
catch (Exception ex) { Console.WriteLine($"[backup] restore-on-start failed: {ex.Message}"); }
}
// ── schedule ────────────────────────────────────────────────────────────────────────
public static void Start()
{
try
{
Directory.CreateDirectory(BackupRoot);
Prune();
_timer = new Timer(_ =>
{
try { CreateBackup(); Prune(); }
catch (Exception ex) { Console.WriteLine($"[backup] scheduled run failed: {ex.Message}"); }
}, null, TimeSpan.FromHours(IntervalHours), TimeSpan.FromHours(IntervalHours));
Console.WriteLine($"[backup] scheduled every {IntervalHours}h, keeping {RetentionDays} days");
}
catch (Exception ex) { Console.WriteLine($"[backup] start failed: {ex.Message}"); }
}
public static void Stop() { _timer?.Dispose(); _timer = null; }
// ── create / prune / list ────────────────────────────────────────────────────────────
public static string CreateBackup()
{
DbMaintenance.CheckpointAll(quiet: true); // merge WAL into the .db files first
Directory.CreateDirectory(BackupRoot);
string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
string dir = Path.Combine(BackupRoot, "backup_" + stamp);
Directory.CreateDirectory(dir);
int n = 0;
if (Directory.Exists(DbDir))
foreach (var f in Directory.GetFiles(DbDir, "*.db"))
{
if (f.EndsWith("-log.db")) continue; // WAL already merged above
try { File.Copy(f, Path.Combine(dir, Path.GetFileName(f)), overwrite: true); n++; }
catch (Exception ex) { Console.WriteLine($"[backup] copy {Path.GetFileName(f)} failed: {ex.Message}"); }
}
Console.WriteLine($"[backup] created backup_{stamp} ({n} files)");
return "backup_" + stamp;
}
public static void Prune()
{
try
{
if (!Directory.Exists(BackupRoot)) return;
var cutoff = DateTime.UtcNow.AddDays(-RetentionDays);
foreach (var d in Directory.GetDirectories(BackupRoot))
{
var ts = ParseStamp(Path.GetFileName(d));
if (ts.HasValue && ts.Value < cutoff)
try { Directory.Delete(d, true); Console.WriteLine($"[backup] pruned {Path.GetFileName(d)}"); } catch { }
}
}
catch (Exception ex) { Console.WriteLine($"[backup] prune failed: {ex.Message}"); }
}
public class BackupInfo
{
public string name { get; set; } = "";
public string createdAt { get; set; } = "";
public long sizeBytes { get; set; }
public int fileCount { get; set; }
}
public static List List()
{
var outp = new List();
if (!Directory.Exists(BackupRoot)) return outp;
foreach (var d in Directory.GetDirectories(BackupRoot).OrderByDescending(x => x))
{
var files = Directory.GetFiles(d, "*.db");
var ts = ParseStamp(Path.GetFileName(d));
outp.Add(new BackupInfo
{
name = Path.GetFileName(d),
createdAt = (ts ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ssZ"),
sizeBytes = files.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0; } }),
fileCount = files.Length
});
}
return outp;
}
// Stage a restore for the next start. Returns false if the named backup doesn't exist.
public static bool StageRestore(string name)
{
name = SafeName(name);
if (name.Length == 0 || !Directory.Exists(Path.Combine(BackupRoot, name))) return false;
Directory.CreateDirectory(BackupRoot);
File.WriteAllText(RestoreMarker, name);
Console.WriteLine($"[backup] staged restore of '{name}' — applies on next start");
return true;
}
// ── helpers ──────────────────────────────────────────────────────────────────────────
private static string SafeName(string? raw) // no path traversal; a plain folder name only
=> Path.GetFileName((raw ?? "").Trim());
private static DateTime? ParseStamp(string folderName)
{
var m = Regex.Match(folderName, @"backup_(\d{8})_(\d{6})");
if (!m.Success) return null;
return DateTime.TryParseExact(m.Groups[1].Value + m.Groups[2].Value, "yyyyMMddHHmmss",
CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)
? dt : (DateTime?)null;
}
}
}