using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
///
/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung.
///
/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und
/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
/// mitsichern will, macht das von Hand.
///
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft,
/// damit eine Änderung ohne Neustart greift.
///
/// Schalter, Uhrzeit, Zielordner und Aufbewahrung gehören der Instanz
/// (#528, Stufe 1). Alle vier standen in den Anwendungseinstellungen und galten damit
/// für jede Instanz: Wer die tägliche Sicherung für eine einschaltete, schaltete sie für
/// alle ein, und alle Archive landeten in einem Ordner. Dort räumt
/// nach dem Präfix backup_<Instanzname>_ auf —
/// bei zwei Namen, die ineinander liegen (Test und Test_2), zählt die eine
/// Instanz die Archive der anderen mit und wirft die überzähligen weg.
///
/// Takt. Früher ein System.Windows.Forms.Timer — der braucht eine
/// Nachrichtenschleife und damit ein Fenster. Jetzt über
/// einen : läuft ohne Oberfläche, driftet nicht, und Tests
/// können die Zeit steuern statt zu warten.
///
public sealed class BackupScheduler : IAsyncDisposable
{
private readonly string _instanceDir;
private readonly InstanceConfig _instance;
private readonly ILogger _logger;
private readonly TimeProvider _clock;
private readonly TimeSpan _tick;
private readonly BackupService _service = new();
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
/// Verhindert mehrere Sicherungen am selben Tag.
private DateTime? _lastRun;
public event Action? OnBackupCreated;
///
/// Das Instanzverzeichnis. Zugleich der Bezugspunkt, gegen den ein relativ
/// eingetragener Zielordner zählt.
///
///
/// Die laufende Instanzkonfiguration — als Verweis, nicht als Abzug: Wer Zielordner
/// oder Uhrzeit in der Oberfläche ändert, ändert dieses Objekt, und der nächste
/// Durchlauf sieht es.
///
public BackupScheduler(
string instanceDir,
InstanceConfig instance,
ILogger logger,
TimeProvider? clock = null,
TimeSpan? tick = null)
{
_instanceDir = instanceDir;
_instance = instance;
_logger = logger;
_clock = clock ?? TimeProvider.System;
// Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
_tick = tick ?? TimeSpan.FromMinutes(1);
}
public void Start() => _loop ??= RunLoopAsync(_cts.Token);
private async Task RunLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(_tick, _clock);
while (await timer.WaitForNextTickAsync(ct))
{
try
{
await TickAsync();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Ein Fehlschlag darf die Anwendung nicht stören.
_logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
}
}
}
/// Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.
public async Task TickAsync()
{
if (!_instance.AutoBackupEnabled)
return;
if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled))
return;
var now = _clock.GetLocalNow().DateTime;
// Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
if (now.TimeOfDay < scheduled) return;
if (_lastRun?.Date == now.Date) return;
// Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht,
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
_lastRun = now;
await RunAsync(
AppHost.ResolveBackupDirectory(_instance, _instanceDir),
_instance.BackupKeepCount,
now);
}
private async Task RunAsync(string folder, int keepCount, DateTime now)
{
// PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
// nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
// Windows nicht mehr anlegen lässt.
var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz");
// Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten
// Lauf erst entstehen.
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
{
Secrets = SecretMode.Exclude,
IncludeChatHistory = true,
IncludeLogs = false
});
_logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
result.ZipPath, result.SizeBytes);
ApplyRotation(folder, safeName, keepCount);
OnBackupCreated?.Invoke(result.ZipPath);
}
/// Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.
private void ApplyRotation(string folder, string safeName, int keepCount)
{
if (keepCount <= 0 || !Directory.Exists(folder))
return;
try
{
var prefix = $"backup_{safeName}_";
var obsolete = new DirectoryInfo(folder)
.GetFiles("*.zip")
.Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison))
.OrderByDescending(f => f.LastWriteTime)
.Skip(keepCount)
.ToList();
foreach (var file in obsolete)
{
file.Delete();
_logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_loop is not null)
{
try { await _loop; }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
}
}