using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;

namespace ClawdDotNet.Core.Backup;

/// <summary>
/// Sichert eine Instanz vollständig und stellt sie wieder her.
///
/// Zwei Dinge sind dabei nicht offensichtlich:
///
/// 1. Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die jüngsten
///    Änderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie wäre
///    veraltet oder in sich widersprüchlich. <c>VACUUM INTO</c> erzeugt dagegen im
///    laufenden Betrieb eine geschlossene, konsistente Kopie.
///
/// 2. Zugangsdaten sind mit DPAPI geschützt und damit an Benutzer und Rechner
///    gebunden. In einer Sicherung wären sie genau dann unbrauchbar, wenn man sie
///    braucht. Sie werden deshalb auf eine Passphrase umgeschlüsselt — oder auf
///    Wunsch weggelassen.
/// </summary>
public sealed class BackupService
{
    private const string ManifestName = "manifest.json";
    private const string DatabaseName = "state.db";

    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

    /// <summary>Wird nie mitgesichert — entweder erzeugt oder unerwünscht.</summary>
    private static readonly string[] AlwaysExcludedDirectories = ["bin", "obj", ".vs"];

    private static readonly string[] AlwaysExcludedExtensions = [".tmp", ".bak"];

    // ═══════════════════════════════════════════════════════════
    //  Sichern
    // ═══════════════════════════════════════════════════════════

    public async Task<BackupResult> CreateAsync(
        string instanceDir, string targetZipPath, BackupOptions options, CancellationToken ct = default)
    {
        if (!Directory.Exists(instanceDir))
            throw new BackupException($"Instanzverzeichnis nicht gefunden: {instanceDir}");

        if (options.Secrets == SecretMode.Passphrase && string.IsNullOrEmpty(options.Passphrase))
            throw new BackupException("Für den Schutz der Zugangsdaten wird eine Passphrase benötigt.");

        var staging = Path.Combine(Path.GetTempPath(), "clawd-backup-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(staging);

        try
        {
            var files = new List<BackupEntry>();
            var secretCount = 0;

            // ─── Datenbank konsistent kopieren ───
            var dbPath = Path.Combine(instanceDir, DatabaseName);
            if (File.Exists(dbPath))
            {
                var target = Path.Combine(staging, DatabaseName);
                CopyDatabaseConsistently(dbPath, target);
                files.Add(await DescribeAsync(staging, target, ct));
            }

            // ─── Übrige Dateien ───
            foreach (var source in EnumerateFiles(instanceDir, options))
            {
                ct.ThrowIfCancellationRequested();

                var relative = Path.GetRelativePath(instanceDir, source);
                var target = Path.Combine(staging, relative);
                Directory.CreateDirectory(Path.GetDirectoryName(target)!);

                if (IsConfigFile(relative))
                {
                    var original = AtomicFile.ReadAllText(source);
                    var (rewritten, count) = RewriteSecretsForBackup(original, options);
                    secretCount += count;
                    await File.WriteAllTextAsync(target, rewritten, ct);
                }
                else
                {
                    File.Copy(source, target, overwrite: true);
                }

                files.Add(await DescribeAsync(staging, target, ct));
            }

            // ─── Manifest ───
            var (instanceId, instanceName) = ReadInstanceIdentity(instanceDir);

            var manifest = new BackupManifest
            {
                CreatedAt = DateTime.Now,
                InstanceId = instanceId,
                InstanceName = instanceName,
                Secrets = options.Secrets.ToString(),
                SecretCount = secretCount,
                Files = files.OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase).ToList()
            };

            await File.WriteAllTextAsync(
                Path.Combine(staging, ManifestName),
                JsonSerializer.Serialize(manifest, JsonOptions), ct);

            // ─── Archiv ───
            Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(targetZipPath))!);
            if (File.Exists(targetZipPath))
                File.Delete(targetZipPath);

            ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal,
                includeBaseDirectory: false);

            return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length);
        }
        finally
        {
            TryDeleteDirectory(staging);
        }
    }

    /// <summary>
    /// Erzeugt eine konsistente Kopie der Datenbank, auch während sie in Benutzung ist.
    /// </summary>
    private static void CopyDatabaseConsistently(string sourcePath, string targetPath)
    {
        var connectionString = new SqliteConnectionStringBuilder
        {
            DataSource = sourcePath,
            Mode = SqliteOpenMode.ReadOnly
        }.ToString();

        using var conn = new SqliteConnection(connectionString);
        conn.Open();

        using var cmd = conn.CreateCommand();
        // Parameter sind in VACUUM INTO nicht erlaubt, deshalb einfache Anführungszeichen
        // im Pfad verdoppeln.
        cmd.CommandText = $"VACUUM INTO '{targetPath.Replace("'", "''")}'";
        cmd.ExecuteNonQuery();
    }

    // ═══════════════════════════════════════════════════════════
    //  Prüfen
    // ═══════════════════════════════════════════════════════════

    /// <summary>Liest das Manifest, ohne etwas auszupacken.</summary>
    public async Task<BackupManifest> InspectAsync(string zipPath, CancellationToken ct = default)
    {
        using var archive = ZipFile.OpenRead(zipPath);

        var entry = archive.GetEntry(ManifestName)
            ?? throw new BackupException("Kein Manifest im Archiv — das ist keine ClawdDotNet-Sicherung.");

        await using var stream = entry.Open();
        using var reader = new StreamReader(stream, Encoding.UTF8);

        var json = await reader.ReadToEndAsync(ct);

        return JsonSerializer.Deserialize<BackupManifest>(json)
            ?? throw new BackupException("Das Manifest ist unlesbar.");
    }

    // ═══════════════════════════════════════════════════════════
    //  Wiederherstellen
    // ═══════════════════════════════════════════════════════════

    public async Task<RestoreResult> RestoreAsync(
        string zipPath, string targetDir, RestoreOptions options, CancellationToken ct = default)
    {
        var manifest = await InspectAsync(zipPath, ct);

        if (manifest.HasSecrets && string.IsNullOrEmpty(options.Passphrase))
            throw new BackupException(
                "Diese Sicherung enthält geschützte Zugangsdaten. Bitte die Passphrase angeben.");

        var written = new List<string>();
        var skipped = new List<string>();
        var wouldOverwrite = new List<string>();

        using var archive = ZipFile.OpenRead(zipPath);

        foreach (var entry in archive.Entries)
        {
            ct.ThrowIfCancellationRequested();

            if (entry.FullName == ManifestName || string.IsNullOrEmpty(entry.Name))
                continue;

            var relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
            var destination = ResolveInside(targetDir, relative);

            // Prüfsumme gegen das Manifest — ein beschädigtes Archiv soll auffallen,
            // bevor etwas überschrieben wird.
            var expected = manifest.Files.FirstOrDefault(
                f => string.Equals(f.Path, entry.FullName, StringComparison.OrdinalIgnoreCase));

            var content = await ReadEntryAsync(entry, ct);

            if (expected is not null && ComputeSha256(content) != expected.Sha256)
            {
                throw new BackupException(
                    $"Prüfsumme stimmt nicht für '{entry.FullName}'. Das Archiv ist beschädigt.");
            }

            if (File.Exists(destination))
            {
                wouldOverwrite.Add(relative);

                if (!options.Overwrite)
                {
                    skipped.Add(relative);
                    continue;
                }
            }

            if (options.DryRun)
                continue;

            var restored = manifest.HasSecrets && IsConfigFile(relative)
                ? Encoding.UTF8.GetBytes(
                    RewriteSecretsForRestore(Encoding.UTF8.GetString(content), options.Passphrase!))
                : content;

            AtomicFile.WriteAllBytes(destination, restored);
            written.Add(relative);
        }

        return new RestoreResult(written, skipped, wouldOverwrite);
    }

    // ═══════════════════════════════════════════════════════════
    //  Zugangsdaten
    // ═══════════════════════════════════════════════════════════

    private static (string Json, int SecretCount) RewriteSecretsForBackup(
        string json, BackupOptions options)
    {
        var count = 0;

        var rewritten = JsonSecretRewriter.Rewrite(json, value =>
        {
            count++;

            // In der Datei liegt der Wert DPAPI-geschützt; für die Sicherung wird er
            // zunächst gelesen und dann anders geschützt.
            string? plain;
            try
            {
                plain = SecretProtector.Unprotect(value);
            }
            catch (SecretProtectionException)
            {
                // Nicht lesbar — etwa weil die Datei von einem anderen Benutzer stammt.
                // Der Wert darf dann nicht als vermeintlicher Klartext weitergereicht
                // werden.
                return null;
            }

            return options.Secrets == SecretMode.Passphrase
                ? PassphraseProtector.Protect(plain, options.Passphrase!)
                : null;
        });

        return (rewritten, count);
    }

    private static string RewriteSecretsForRestore(string json, string passphrase)
        => JsonSecretRewriter.Rewrite(json, value =>
        {
            if (!PassphraseProtector.IsProtected(value))
                return value;

            var plain = PassphraseProtector.Unprotect(value, passphrase);

            // Zurück auf DPAPI des Zielrechners.
            return SecretProtector.Protect(plain);
        });

    // ═══════════════════════════════════════════════════════════
    //  Hilfsfunktionen
    // ═══════════════════════════════════════════════════════════

    private static IEnumerable<string> EnumerateFiles(string instanceDir, BackupOptions options)
    {
        foreach (var path in Directory.EnumerateFiles(instanceDir, "*", SearchOption.AllDirectories))
        {
            var relative = Path.GetRelativePath(instanceDir, path);
            var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

            if (segments.Any(s => AlwaysExcludedDirectories.Contains(s, StringComparer.OrdinalIgnoreCase)))
                continue;

            var name = Path.GetFileName(path);

            // Die Datenbank wird gesondert behandelt; die WAL-Begleitdateien gehören
            // nicht ins Archiv, weil VACUUM INTO sie bereits einarbeitet.
            if (name is DatabaseName or DatabaseName + "-wal" or DatabaseName + "-shm")
                continue;

            if (AlwaysExcludedExtensions.Contains(Path.GetExtension(name), StringComparer.OrdinalIgnoreCase))
                continue;

            if (name.Contains(".tmp_", StringComparison.OrdinalIgnoreCase))
                continue;

            if (!options.IncludeLogs &&
                segments.Any(s => s.Equals("Logs", StringComparison.OrdinalIgnoreCase)))
                continue;

            if (!options.IncludeChatHistory &&
                name is "ChatHistory.json" or "ChatContext.json")
                continue;

            yield return path;
        }
    }

    private static bool IsConfigFile(string relativePath)
    {
        var name = Path.GetFileName(relativePath);
        return name.Equals("InstanceSettings.json", StringComparison.OrdinalIgnoreCase)
            || name.Equals("AgentSettings.json", StringComparison.OrdinalIgnoreCase);
    }

    private static (string Id, string Name) ReadInstanceIdentity(string instanceDir)
    {
        var path = Path.Combine(instanceDir, "InstanceSettings.json");
        if (!File.Exists(path))
            return ("", Path.GetFileName(instanceDir.TrimEnd(Path.DirectorySeparatorChar)));

        try
        {
            using var doc = JsonDocument.Parse(AtomicFile.ReadAllText(path));
            var root = doc.RootElement;

            return (
                root.TryGetProperty("instanceId", out var id) ? id.GetString() ?? "" : "",
                root.TryGetProperty("instanceName", out var n) ? n.GetString() ?? "" : "");
        }
        catch (JsonException)
        {
            return ("", "");
        }
    }

    private static async Task<BackupEntry> DescribeAsync(string root, string file, CancellationToken ct)
    {
        var bytes = await File.ReadAllBytesAsync(file, ct);

        return new BackupEntry(
            Path.GetRelativePath(root, file).Replace(Path.DirectorySeparatorChar, '/'),
            bytes.LongLength,
            ComputeSha256(bytes));
    }

    private static async Task<byte[]> ReadEntryAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        await using var stream = entry.Open();
        using var buffer = new MemoryStream();
        await stream.CopyToAsync(buffer, ct);
        return buffer.ToArray();
    }

    private static string ComputeSha256(byte[] content)
        => Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();

    /// <summary>
    /// Verhindert, dass ein präpariertes Archiv über Einträge wie <c>..\..\evil</c>
    /// außerhalb des Zielverzeichnisses schreibt.
    /// </summary>
    private static string ResolveInside(string targetDir, string relative)
    {
        var root = Path.GetFullPath(targetDir);
        var full = Path.GetFullPath(Path.Combine(root, relative));

        // Der Vergleich muss dem Dateisystem folgen: Unter Linux sind "Ziel" und "ziel"
        // zwei Verzeichnisse, und ein Eintrag darf auch nicht über eine symbolische
        // Verknüpfung hinauszeigen. Beides steckt in PathBoundary.
        if (!PathBoundary.IsInside(full, root))
            throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus.");

        return full;
    }

    private static void TryDeleteDirectory(string path)
    {
        try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
        catch { /* Aufräumen darf den eigentlichen Vorgang nicht stören */ }
    }
}
