namespace ClawdDotNet.Core.Storage;

/// <summary>
/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut.
///
/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit
/// <c>OrdinalIgnoreCase</c> — die Annahme von Windows, dass Groß- und Kleinschreibung
/// keine Rolle spielt. Unter Linux ist das falsch: <c>/home/x/Workspace</c> und
/// <c>/home/x/workspace</c> sind zwei verschiedene Verzeichnisse. Ein Kandidat im
/// zweiten würde als „innerhalb" des ersten durchgehen.
///
/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung.
///
/// Zweiter Punkt, den es unter Windows so nicht gab: <b>symbolische Verknüpfungen</b>.
/// <c>Path.GetFullPath</c> löst sie nicht auf — es rechnet nur <c>..</c> heraus. Legt
/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach <c>/etc</c> an, liegt
/// <c>workspace/etc/passwd</c> nach reiner Zeichenkettenrechnung innerhalb, zeigt aber
/// hinaus. <see cref="Canonicalize"/> löst deshalb jeden Pfadabschnitt auf.
/// </summary>
public static class PathBoundary
{
    /// <summary>
    /// Wie das Dateisystem Pfade vergleicht.
    ///
    /// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist
    /// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber
    /// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt,
    /// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab,
    /// statt zu wenig.
    /// </summary>
    public static StringComparison Comparison { get; } =
        OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
            ? StringComparison.OrdinalIgnoreCase
            : StringComparison.Ordinal;

    /// <inheritdoc cref="Comparison"/>
    public static StringComparer Comparer { get; } =
        OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
            ? StringComparer.OrdinalIgnoreCase
            : StringComparer.Ordinal;

    /// <summary>Absoluter Pfad mit genau einem abschließenden Trenner.</summary>
    public static string NormalizeDirectory(string path)
    {
        var full = Path.GetFullPath(path);
        return full.EndsWith(Path.DirectorySeparatorChar)
            ? full
            : full + Path.DirectorySeparatorChar;
    }

    /// <summary>
    /// Prüft, ob <paramref name="candidate"/> im Verzeichnis <paramref name="root"/> liegt.
    ///
    /// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne
    /// den abschließenden Trenner gälte <c>…/Workspace-Backup</c> als Teil von
    /// <c>…/Workspace</c>.
    ///
    /// Symbolische Verknüpfungen werden aufgelöst (<see cref="Canonicalize"/>) — ein
    /// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
    /// </summary>
    public static bool IsInside(string candidate, string root)
    {
        var normalizedRoot = Canonicalize(NormalizeDirectory(root));
        var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));

        // Der Root selbst gilt als innerhalb.
        if (string.Equals(
                normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
                normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
                Comparison))
        {
            return true;
        }

        return normalizedCandidate.StartsWith(
            NormalizeDirectory(normalizedRoot), Comparison);
    }

    /// <summary>
    /// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen.
    ///
    /// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort
    /// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen.
    /// </summary>
    public static string CanonicalKey(string path)
    {
        var full = Path.GetFullPath(path);
        return Comparison == StringComparison.OrdinalIgnoreCase
            ? full.ToLowerInvariant()
            : full;
    }

    /// <summary>
    /// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
    ///
    /// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt
    /// <c>workspace/daten</c> nach <c>/etc</c>, dann liegt <c>workspace/daten/passwd</c>
    /// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung
    /// ist. <c>ResolveLinkTarget(returnFinalTarget: true)</c> folgt dabei ganzen Ketten,
    /// ein Aufruf je Abschnitt genügt also.
    ///
    /// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
    /// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
    /// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
    /// </summary>
    public static string Canonicalize(string path)
    {
        var full = Path.GetFullPath(path);

        var root = Path.GetPathRoot(full);
        if (string.IsNullOrEmpty(root))
            return full;

        var rest = full[root.Length..]
            .Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
                StringSplitOptions.RemoveEmptyEntries);

        var current = root;

        foreach (var segment in rest)
        {
            current = Path.Combine(current, segment);

            string? target;
            try
            {
                // Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
                // nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
                target = Directory.Exists(current)
                    ? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
                    : File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
            }
            catch (IOException)
            {
                // Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
                // wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
                // dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
                return full;
            }
            catch (UnauthorizedAccessException)
            {
                return full;
            }

            if (!string.IsNullOrEmpty(target))
            {
                current = Path.IsPathRooted(target)
                    ? target
                    : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target));
            }
        }

        return Path.GetFullPath(current);
    }
}
