using System.Collections.Concurrent;
using System.Text;

namespace ClawdDotNet.Core.Storage;

/// <summary>
/// Schreibt Dateien so, dass ein Absturz keine halbe Datei hinterlässt.
///
/// Hintergrund: Alle Konfigurations- und Zustandsdateien wurden mit
/// <c>File.WriteAllText</c> geschrieben. Das kürzt die Zieldatei zuerst auf null und
/// füllt sie dann — bricht der Vorgang dazwischen ab, ist der alte Inhalt weg und der
/// neue unvollständig.
///
/// Das ist bereits eingetreten: In einer Instanz lag eine
/// <c>TokenUsage.json.corrupt_…</c>, die die Fehlerbehandlung beiseitegelegt hatte.
///
/// Ablauf hier: in eine Nebendatei schreiben, auf die Platte zwingen, dann durch
/// Umbenennen ersetzen. Das Ersetzen ist auf NTFS atomar — es gibt keinen Zeitpunkt,
/// zu dem die Zieldatei halb beschrieben wäre.
/// </summary>
public static class AtomicFile
{
    private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);

    /// <summary>
    /// Ein Schloss je Zieldatei.
    ///
    /// Zwei gleichzeitige Schreibvorgänge auf dieselbe Datei sind ohnehin ein Rennen —
    /// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
    /// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
    /// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
    ///
    /// Der Schlüssel kommt aus <see cref="PathBoundary.CanonicalKey"/>: Unter Windows
    /// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe
    /// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen.
    /// </summary>
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();

    private static SemaphoreSlim LockFor(string fullPath)
        => PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1));

    /// <summary>
    /// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
    ///
    /// <c>File.ReadAllText</c> öffnet ohne Freigabe zum Löschen — solange der Lesevorgang
    /// läuft, lässt Windows die Datei nicht ersetzen. Ein Leser könnte damit einen
    /// Schreiber scheitern lassen.
    ///
    /// Wird die Datei während des Lesens ersetzt, liefert der bereits geöffnete Griff
    /// weiterhin den alten Inhalt — vollständig und in sich stimmig. Genau das ist
    /// gewünscht: nie ein halber Stand.
    /// </summary>
    public static string ReadAllText(string path, Encoding? encoding = null)
    {
        using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
            FileShare.ReadWrite | FileShare.Delete);
        using var reader = new StreamReader(stream, encoding ?? Utf8NoBom, detectEncodingFromByteOrderMarks: true);
        return reader.ReadToEnd();
    }

    public static void WriteAllText(string path, string content, Encoding? encoding = null)
        => WriteAllBytes(path, (encoding ?? Utf8NoBom).GetBytes(content));

    public static async Task WriteAllTextAsync(
        string path, string content, Encoding? encoding = null, CancellationToken ct = default)
        => await WriteAllBytesAsync(path, (encoding ?? Utf8NoBom).GetBytes(content), ct);

    public static void WriteAllBytes(string path, byte[] bytes)
    {
        var (fullPath, tempPath) = PreparePaths(path);
        var gate = LockFor(fullPath);

        gate.Wait();
        try
        {
            using (var stream = CreateTempStream(tempPath))
            {
                stream.Write(bytes, 0, bytes.Length);
                // Ohne dieses Flush lägen die Daten nur im Schreibcache des
                // Betriebssystems — bei Stromausfall wäre die Umbenennung erfolgt,
                // der Inhalt aber nicht auf der Platte.
                stream.Flush(flushToDisk: true);
            }

            Commit(tempPath, fullPath);
        }
        catch
        {
            TryDelete(tempPath);
            throw;
        }
        finally
        {
            gate.Release();
        }
    }

    public static async Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken ct = default)
    {
        var (fullPath, tempPath) = PreparePaths(path);
        var gate = LockFor(fullPath);

        await gate.WaitAsync(ct);
        try
        {
            await using (var stream = CreateTempStream(tempPath))
            {
                await stream.WriteAsync(bytes, ct);
                stream.Flush(flushToDisk: true);
            }

            Commit(tempPath, fullPath);
        }
        catch
        {
            TryDelete(tempPath);
            throw;
        }
        finally
        {
            gate.Release();
        }
    }

    // ─── Bausteine ───

    private static (string FullPath, string TempPath) PreparePaths(string path)
    {
        var fullPath = Path.GetFullPath(path);

        var directory = Path.GetDirectoryName(fullPath);
        if (!string.IsNullOrEmpty(directory))
            Directory.CreateDirectory(directory);

        // Eindeutiger Name, damit parallele Schreibvorgänge sich nicht die
        // Nebendatei streitig machen.
        var tempPath = fullPath + ".tmp_" + Guid.NewGuid().ToString("N")[..8];

        return (fullPath, tempPath);
    }

    private static FileStream CreateTempStream(string tempPath)
        => new(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);

    /// <summary>Versuche für das Ersetzen — siehe <see cref="Commit"/>.</summary>
    private const int CommitAttempts = 20;

    /// <summary>Obergrenze der Wartezeit zwischen zwei Versuchen.</summary>
    private static readonly TimeSpan MaxCommitBackoff = TimeSpan.FromMilliseconds(200);

    /// <summary>
    /// Ersetzt die Zieldatei durch die Nebendatei.
    ///
    /// Bewusst <c>File.Replace</c> und nicht <c>File.Move(overwrite: true)</c>. Gemessen
    /// auf Windows, mit einem Leser, der die Zieldatei geöffnet hält:
    ///
    /// <code>
    /// Freigabe des Lesers      File.Move   File.Replace
    /// Read                     scheitert   scheitert
    /// ReadWrite                scheitert   scheitert
    /// ReadWrite | Delete       scheitert   funktioniert
    /// </code>
    ///
    /// <c>File.Move</c> verlangt die Zieldatei exklusiv und scheitert deshalb immer,
    /// sobald jemand sie geöffnet hat. <c>File.Replace</c> kommt damit zurecht, sofern
    /// der Leser das Löschen freigibt — dafür gibt es <see cref="ReadAllText"/>.
    ///
    /// Wiederholt wird trotzdem: Fremde Leser wie Virenscanner oder Sicherungsläufe
    /// öffnen ohne diese Freigabe. Solche Sperren sind kurzlebig.
    /// </summary>
    private static void Commit(string tempPath, string fullPath)
    {
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                // File.Replace verlangt eine vorhandene Zieldatei.
                if (File.Exists(fullPath))
                {
                    File.Replace(tempPath, fullPath,
                        destinationBackupFileName: null, ignoreMetadataErrors: true);
                }
                else
                {
                    File.Move(tempPath, fullPath);
                }

                return;
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
                                       && attempt < CommitAttempts)
            {
                var wait = Math.Min(attempt * 20, (int)MaxCommitBackoff.TotalMilliseconds);
                Thread.Sleep(wait);
            }
        }
    }

    private static void TryDelete(string path)
    {
        try { if (File.Exists(path)) File.Delete(path); }
        catch { /* Aufräumen darf den eigentlichen Fehler nicht verdecken */ }
    }
}
