// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.Collections.Generic;
using System.Security.AccessControl;
using System.IO;
using System.Linq;

using Duplicati.Library.Interface;
using Newtonsoft.Json;
using System.Runtime.Versioning;
using System.Security.Principal;

namespace Duplicati.Library.Common.IO
{
    [SupportedOSPlatform("windows")]
    public struct SystemIOWindows : ISystemIO
    {
        // Based on the constant names used in
        // https://github.com/dotnet/runtime/blob/v5.0.12/src/libraries/Common/src/System/IO/PathInternal.Windows.cs
        private const string ExtendedDevicePathPrefix = @"\\?\";
        private const string UncPathPrefix = @"\\";
        private const string AltUncPathPrefix = @"//";
        private const string UncExtendedPathPrefix = @"\\?\UNC\";

        private static readonly string DIRSEP = Util.DirectorySeparatorString;

        /// <summary>
        /// The current user SID
        /// </summary>
        private static readonly SecurityIdentifier CURRENT_USER_SID = WindowsIdentity.GetCurrent().User;

        /// <summary>
        /// The LocalSystem user SID
        /// </summary>
        private static readonly SecurityIdentifier LOCAL_SYSTEM_SID = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);

        /// <summary>
        /// The Administrator user SID
        /// </summary>
        private static readonly SecurityIdentifier ADMINISTRATORS_SID = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);

        /// <summary>
        /// Prefix path with one of the extended device path prefixes
        /// (@"\\?\" or @"\\?\UNC\") but only if it's a fully qualified
        /// path with no relative components (i.e., with no "." or ".."
        /// as part of the path).
        /// </summary>
        public static string AddExtendedDevicePathPrefix(string path)
        {
            if (IsPrefixedWithExtendedDevicePathPrefix(path))
            {
                // For example: \\?\C:\Temp\foo.txt or \\?\UNC\example.com\share\foo.txt
                return path;
            }
            else
            {
                var hasRelativePathComponents = HasRelativePathComponents(path);
                if (IsPrefixedWithUncPathPrefix(path) && !hasRelativePathComponents)
                {
                    // For example: \\example.com\share\foo.txt or //example.com/share/foo.txt
                    return UncExtendedPathPrefix + ConvertSlashes(path.Substring(UncPathPrefix.Length));
                }
                else if (DotNetRuntimePathWindows.IsPathFullyQualified(path) && !hasRelativePathComponents)
                {
                    // For example: C:\Temp\foo.txt or C:/Temp/foo.txt
                    return ExtendedDevicePathPrefix + ConvertSlashes(path);
                }
                else
                {
                    // A relative path or a fully qualified path with relative
                    // path components so the extended device path prefixes
                    // cannot be applied.
                    //
                    // For example: foo.txt or C:\Temp\..\foo.txt
                    return path;
                }
            }
        }

        /// <summary>
        /// Returns true if prefixed with @"\\" or @"//".
        /// </summary>
        private static bool IsPrefixedWithUncPathPrefix(string path)
        {
            return path.StartsWith(UncPathPrefix, StringComparison.Ordinal) ||
                path.StartsWith(AltUncPathPrefix, StringComparison.Ordinal);
        }

        /// <summary>
        /// Returns true if prefixed with @"\\?\UNC\" or @"\\?\".
        /// </summary>
        private static bool IsPrefixedWithExtendedDevicePathPrefix(string path)
        {
            return path.StartsWith(UncExtendedPathPrefix, StringComparison.Ordinal) ||
                path.StartsWith(ExtendedDevicePathPrefix, StringComparison.Ordinal);
        }

        private static string[] relativePathComponents = new[] { ".", ".." };

        /// <summary>
        /// Returns true if <paramref name="path"/> contains relative path components; i.e., "." or "..".
        /// </summary>
        private static bool HasRelativePathComponents(string path)
        {
            return GetPathComponents(path).Any(pathComponent => relativePathComponents.Contains(pathComponent));
        }

        /// <summary>
        /// Returns a sequence representing the files and directories in <paramref name="path"/>.
        /// </summary>
        private static IEnumerable<string> GetPathComponents(string path)
        {
            while (!String.IsNullOrEmpty(path))
            {
                var pathComponent = Path.GetFileName(path);
                if (!String.IsNullOrEmpty(pathComponent))
                {
                    yield return pathComponent;
                }
                path = Path.GetDirectoryName(path);
            }
        }

        /// <summary>
        /// Removes either of the extended device path prefixes
        /// (@"\\?\" or @"\\?\UNC\") if <paramref name="path"/> is prefixed
        /// with one of them.
        /// </summary>
        public static string RemoveExtendedDevicePathPrefix(string path)
        {
            if (path.StartsWith(UncExtendedPathPrefix, StringComparison.Ordinal))
            {
                // @"\\?\UNC\example.com\share\file.txt" to @"\\example.com\share\file.txt"
                return UncPathPrefix + path.Substring(UncExtendedPathPrefix.Length);
            }
            else if (path.StartsWith(ExtendedDevicePathPrefix, StringComparison.Ordinal))
            {
                // @"\\?\C:\file.txt" to @"C:\file.txt"
                return path.Substring(ExtendedDevicePathPrefix.Length);
            }
            else
            {
                return path;
            }
        }

        /// <summary>
        /// Convert forward slashes to backslashes.
        /// </summary>
        /// <returns>Path with forward slashes replaced by backslashes.</returns>
        private static string ConvertSlashes(string path)
        {
            return path.Replace("/", Util.DirectorySeparatorString);
        }


        [SupportedOSPlatform("windows")]
        private class FileSystemAccess
        {
            // Use JsonProperty Attribute to allow readonly fields to be set by deserializer
            // https://github.com/duplicati/duplicati/issues/4028
            [JsonProperty]
            public readonly FileSystemRights Rights;
            [JsonProperty]
            public readonly AccessControlType ControlType;
            [JsonProperty]
            public readonly string SID;
            [JsonProperty]
            public readonly bool Inherited;
            [JsonProperty]
            public readonly InheritanceFlags Inheritance;
            [JsonProperty]
            public readonly PropagationFlags Propagation;

            public FileSystemAccess()
            {
            }

            public FileSystemAccess(FileSystemAccessRule rule)
            {
                Rights = rule.FileSystemRights;
                ControlType = rule.AccessControlType;
                SID = rule.IdentityReference.Value;
                Inherited = rule.IsInherited;
                Inheritance = rule.InheritanceFlags;
                Propagation = rule.PropagationFlags;
            }

            public FileSystemAccessRule Create(FileSystemSecurity owner)
            {
                return (FileSystemAccessRule)owner.AccessRuleFactory(
                    new System.Security.Principal.SecurityIdentifier(SID),
                    (int)Rights,
                    Inherited,
                    Inheritance,
                    Propagation,
                    ControlType);
            }
        }

        private static JsonSerializer _cachedSerializer;

        private JsonSerializer Serializer
        {
            get
            {
                if (_cachedSerializer != null)
                {
                    return _cachedSerializer;
                }

                _cachedSerializer = JsonSerializer.Create(
                    new JsonSerializerSettings { Culture = System.Globalization.CultureInfo.InvariantCulture });

                return _cachedSerializer;
            }
        }

        private string SerializeObject<T>(T o)
        {
            using (var tw = new StringWriter())
            {
                Serializer.Serialize(tw, o);
                tw.Flush();
                return tw.ToString();
            }
        }

        private T DeserializeObject<T>(string data)
        {
            using (var tr = new StringReader(data))
                return (T)Serializer.Deserialize(tr, typeof(T));
        }


        [SupportedOSPlatform("windows")]
        private FileSystemSecurity GetAccessControlDir(string path)
            => new DirectoryInfo(AddExtendedDevicePathPrefix(path)).GetAccessControl();


        [SupportedOSPlatform("windows")]
        private FileSystemSecurity GetAccessControlFile(string path)
            => new FileInfo(AddExtendedDevicePathPrefix(path)).GetAccessControl();


        [SupportedOSPlatform("windows")]
        private void SetAccessControlFile(string path, FileSecurity rules)
            => new FileInfo(AddExtendedDevicePathPrefix(path)).SetAccessControl(rules);

        [SupportedOSPlatform("windows")]
        private void SetAccessControlDir(string path, DirectorySecurity rules)
            => new DirectoryInfo(AddExtendedDevicePathPrefix(path)).SetAccessControl(rules);

        #region ISystemIO implementation
        public void DirectoryCreate(string path)
            => Directory.CreateDirectory(AddExtendedDevicePathPrefix(path));

        public void DirectoryDelete(string path, bool recursive)
            => Directory.Delete(AddExtendedDevicePathPrefix(path), recursive);

        public bool DirectoryExists(string path)
            => Directory.Exists(AddExtendedDevicePathPrefix(path));

        public void DirectoryMove(string sourceDirName, string destDirName)
            => Directory.Move(AddExtendedDevicePathPrefix(sourceDirName), AddExtendedDevicePathPrefix(destDirName));

        public void FileDelete(string path)
            => File.Delete(AddExtendedDevicePathPrefix(path));

        public void FileSetLastWriteTimeUtc(string path, DateTime time)
            => File.SetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path), time);

        public void FileSetCreationTimeUtc(string path, DateTime time)
            => File.SetCreationTimeUtc(AddExtendedDevicePathPrefix(path), time);

        public DateTime FileGetLastWriteTimeUtc(string path)
            => File.GetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path));

        public DateTime FileGetCreationTimeUtc(string path)
            => File.GetCreationTimeUtc(AddExtendedDevicePathPrefix(path));

        public bool FileExists(string path)
            => File.Exists(AddExtendedDevicePathPrefix(path));

        public FileStream FileOpenRead(string path)
            => File.Open(AddExtendedDevicePathPrefix(path), FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

        public FileStream FileOpenReadWrite(string path)
            => !FileExists(path)
                ? FileCreate(path)
                : File.Open(AddExtendedDevicePathPrefix(path), FileMode.Open, FileAccess.ReadWrite);

        public FileStream FileOpenWrite(string path)
            => !FileExists(path)
                ? FileCreate(path)
                : File.OpenWrite(AddExtendedDevicePathPrefix(path));

        public FileStream FileCreate(string path)
            => File.Create(AddExtendedDevicePathPrefix(path));

        public FileAttributes GetFileAttributes(string path)
            => File.GetAttributes(AddExtendedDevicePathPrefix(path));

        public void SetFileAttributes(string path, FileAttributes attributes)
        {
            File.SetAttributes(AddExtendedDevicePathPrefix(path), attributes);
        }

        /// <summary>
        /// Returns the symlink target if the entry is a symlink, and null otherwise
        /// </summary>
        /// <param name="file">The file or folder to examine</param>
        /// <returns>The symlink target</returns>
        public string GetSymlinkTarget(string file)
            => new FileInfo(AddExtendedDevicePathPrefix(file)).LinkTarget;

        public IEnumerable<string> EnumerateFileSystemEntries(string path)
            => Directory.EnumerateFileSystemEntries(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix);

        public IEnumerable<string> EnumerateFiles(string path)
            => Directory.EnumerateFiles(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix);

        public IEnumerable<string> EnumerateFiles(string path, string searchPattern, SearchOption searchOption)
            => Directory.EnumerateFiles(AddExtendedDevicePathPrefix(path), searchPattern, searchOption).Select(RemoveExtendedDevicePathPrefix);

        public string PathGetFileName(string path)
            => RemoveExtendedDevicePathPrefix(Path.GetFileName(AddExtendedDevicePathPrefix(path)));

        public string PathGetDirectoryName(string path)
            => RemoveExtendedDevicePathPrefix(Path.GetDirectoryName(AddExtendedDevicePathPrefix(path)));

        public string PathGetExtension(string path)
            => RemoveExtendedDevicePathPrefix(Path.GetExtension(AddExtendedDevicePathPrefix(path)));

        public string PathChangeExtension(string path, string extension)
            => RemoveExtendedDevicePathPrefix(Path.ChangeExtension(AddExtendedDevicePathPrefix(path), extension));

        public void DirectorySetLastWriteTimeUtc(string path, DateTime time)
        {
            Directory.SetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path), time);
        }

        public void DirectorySetCreationTimeUtc(string path, DateTime time)
        {
            Directory.SetCreationTimeUtc(AddExtendedDevicePathPrefix(path), time);
        }

        public void FileMove(string source, string target)
        {
            File.Move(AddExtendedDevicePathPrefix(source), AddExtendedDevicePathPrefix(target));
        }

        public long FileLength(string path)
            => new FileInfo(AddExtendedDevicePathPrefix(path)).Length;

        public string GetPathRoot(string path)
            => IsPrefixedWithExtendedDevicePathPrefix(path)
                ? Path.GetPathRoot(path)
                : RemoveExtendedDevicePathPrefix(Path.GetPathRoot(AddExtendedDevicePathPrefix(path)));
        public string[] GetDirectories(string path)
            => IsPrefixedWithExtendedDevicePathPrefix(path)
                ? Directory.GetDirectories(path)
                : Directory.GetDirectories(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix).ToArray();

        public string[] GetFiles(string path)
            => IsPrefixedWithExtendedDevicePathPrefix(path)
                ? Directory.GetFiles(path)
                : Directory.GetFiles(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix).ToArray();

        public string[] GetFiles(string path, string searchPattern)
            => IsPrefixedWithExtendedDevicePathPrefix(path)
                ? Directory.GetFiles(path, searchPattern)
                : Directory.GetFiles(AddExtendedDevicePathPrefix(path), searchPattern).Select(RemoveExtendedDevicePathPrefix).ToArray();

        public DateTime GetCreationTimeUtc(string path)
            => Directory.GetCreationTimeUtc(AddExtendedDevicePathPrefix(path));

        public DateTime GetLastWriteTimeUtc(string path)
            => Directory.GetLastWriteTimeUtc(AddExtendedDevicePathPrefix(path));

        public IEnumerable<string> EnumerateDirectories(string path)
            => IsPrefixedWithExtendedDevicePathPrefix(path)
                ? Directory.EnumerateDirectories(path)
                : Directory.EnumerateDirectories(AddExtendedDevicePathPrefix(path)).Select(RemoveExtendedDevicePathPrefix);

        public IEnumerable<IFileEntry> EnumerateFileEntries(string path)
        {
            // For consistency with previous implementation, enumerate files first and directories after
            var dir = IsPrefixedWithExtendedDevicePathPrefix(path)
                ? new DirectoryInfo(path)
                : new DirectoryInfo(AddExtendedDevicePathPrefix(path));

            foreach (FileInfo file in dir.EnumerateFiles())
                yield return FileEntry(file);

            foreach (DirectoryInfo d in dir.EnumerateDirectories())
                yield return DirectoryEntry(d);
        }

        public void FileCopy(string source, string target, bool overwrite)
            => File.Copy(AddExtendedDevicePathPrefix(source), AddExtendedDevicePathPrefix(target), overwrite);

        public string PathGetFullPath(string path)
        {
            // Desired behavior:
            // 1. If path is already prefixed with \\?\, it should be left untouched
            // 2. If path is not already prefixed with \\?\, the return value should also not be prefixed
            // 3. If path is relative or has relative components, that should be resolved by calling Path.GetFullPath()
            // 4. If path is not relative and has no relative components, prefix with \\?\ to prevent normalization from munging "problematic Windows paths"
            return IsPrefixedWithExtendedDevicePathPrefix(path)
                ? path
                : RemoveExtendedDevicePathPrefix(Path.GetFullPath(AddExtendedDevicePathPrefix(path)));
        }

        public IFileEntry DirectoryEntry(string path)
            => DirectoryEntry(new DirectoryInfo(AddExtendedDevicePathPrefix(path)));

        public IFileEntry DirectoryEntry(DirectoryInfo dInfo)
            => new FileEntry(dInfo.Name, 0, dInfo.LastAccessTime, dInfo.LastWriteTime)
            {
                IsFolder = true
            };

        public IFileEntry FileEntry(string path)
            => FileEntry(new FileInfo(AddExtendedDevicePathPrefix(path)));

        public IFileEntry FileEntry(FileInfo fileInfo)
        {
            var lastAccess = new DateTime();
            try
            {
                // Internally this will convert the FILETIME value from Windows API to a
                // DateTime. If the value represents a date after 12/31/9999 it will throw
                // ArgumentOutOfRangeException, because this is not supported by DateTime.
                // Some file systems seem to set strange access timestamps on files, which
                // may lead to this exception being thrown. Since the last accessed
                // timestamp is not important such exeptions are just silently ignored.
                lastAccess = fileInfo.LastAccessTime;
            }
            catch { }
            return new FileEntry(fileInfo.Name, fileInfo.Length, lastAccess, fileInfo.LastWriteTime);
        }

        [SupportedOSPlatform("windows")]
        public Dictionary<string, string> GetMetadata(string path, bool isSymlink, bool followSymlink)
        {
            var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal);
            var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path;
            var dict = new Dictionary<string, string>();

            FileSystemSecurity rules = isDirTarget ? GetAccessControlDir(targetpath) : GetAccessControlFile(targetpath);
            var objs = new List<FileSystemAccess>();
            foreach (var f in rules.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)))
                objs.Add(new FileSystemAccess((FileSystemAccessRule)f));

            dict["win-ext:accessrules"] = SerializeObject(objs);

            // Only include the following key when its value is True.
            // This prevents unnecessary 'metadata change' detections when upgrading from
            // older versions (pre-2.0.5.101) that didn't store this value at all.
            // When key is not present, its value is presumed False by the restore code.
            if (rules.AreAccessRulesProtected)
            {
                dict["win-ext:accessrulesprotected"] = "True";
            }

            return dict;
        }

        [SupportedOSPlatform("windows")]
        public void SetMetadata(string path, Dictionary<string, string> data, bool restorePermissions)
        {
            if (restorePermissions)
            {
                var isDirTarget = path.EndsWith(DIRSEP, StringComparison.Ordinal);
                var targetpath = isDirTarget ? path.Substring(0, path.Length - 1) : path;

                FileSystemSecurity rules = isDirTarget ? GetAccessControlDir(targetpath) : GetAccessControlFile(targetpath);

                if (data.ContainsKey("win-ext:accessrulesprotected"))
                {
                    bool isProtected = bool.Parse(data["win-ext:accessrulesprotected"]);
                    if (rules.AreAccessRulesProtected != isProtected)
                        rules.SetAccessRuleProtection(isProtected, false);
                }

                if (data.ContainsKey("win-ext:accessrules"))
                {
                    var content = DeserializeObject<FileSystemAccess[]>(data["win-ext:accessrules"]);
                    var c = rules.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier));
                    for (var i = c.Count - 1; i >= 0; i--)
                        rules.RemoveAccessRule((FileSystemAccessRule)c[i]);

                    Exception ex = null;

                    foreach (var r in content)
                    {
                        // Attempt to apply as many rules as we can
                        try
                        {
                            rules.AddAccessRule(r.Create(rules));
                        }
                        catch (Exception e)
                        {
                            ex = e;
                        }
                    }

                    if (ex != null)
                        throw ex;
                }

                if (isDirTarget)
                    SetAccessControlDir(targetpath, (DirectorySecurity)rules);
                else
                    SetAccessControlFile(targetpath, (FileSecurity)rules);
            }
        }

        public string PathCombine(params string[] paths)
            => Path.Combine(paths);

        public void CreateSymlink(string symlinkfile, string target, bool asDir)
        {
            if (FileExists(symlinkfile) || DirectoryExists(symlinkfile))
                throw new IOException(string.Format("File already exists: {0}", symlinkfile));


            if (asDir)
            {
                Directory.CreateSymbolicLink(AddExtendedDevicePathPrefix(symlinkfile), target);
            }
            else
            {
                File.CreateSymbolicLink(AddExtendedDevicePathPrefix(symlinkfile), target);
            }

            //Sadly we do not get a notification if the creation fails :(
            FileAttributes attr = 0;
            if ((!asDir && FileExists(symlinkfile)) || (asDir && DirectoryExists(symlinkfile)))
                try { attr = GetFileAttributes(symlinkfile); }
                catch { }

            if ((attr & FileAttributes.ReparsePoint) == 0)
                throw new IOException(string.Format("Unable to create symlink, check account permissions: {0}", symlinkfile));
        }

        /// <summary>
        /// Sets the file permissions to user read-write only.
        /// </summary>
        /// <param name="path">The file to set permissions on.</param>
        public void FileSetPermissionUserRWOnly(string path)
        {
            // Create directory security settings
            var security = new FileSecurity();

            // Remove inherited permissions to ensure only the current user has access
            security.SetAccessRuleProtection(true, false);

            var users = new[] {
                CURRENT_USER_SID,
                LOCAL_SYSTEM_SID,
                ADMINISTRATORS_SID
            }.ToHashSet();

            // Grant the users read access
            foreach (var user in users)
                security.AddAccessRule(new FileSystemAccessRule(
                    user,
                    FileSystemRights.FullControl,
                    AccessControlType.Allow
                ));

            // Adjust with the new security settings
            new FileInfo(path).SetAccessControl(security);
        }

        /// <summary>
        /// Sets the directory permissions to read-write only for the current user.
        /// </summary>
        /// <param name="path">The directory to set permissions on.</param>
        public void DirectorySetPermissionUserRWOnly(string path)
        {
            // Create directory security settings
            var security = new DirectorySecurity();

            // Remove inherited permissions to ensure only the current user has access
            security.SetAccessRuleProtection(true, false);

            var users = new[] {
                CURRENT_USER_SID,
                LOCAL_SYSTEM_SID,
                ADMINISTRATORS_SID
            }.ToHashSet();

            // Grant the users full access
            foreach (var user in users)
                security.AddAccessRule(new FileSystemAccessRule(
                    user,
                    FileSystemRights.FullControl, // Using full-control to allow changing permissions as well
                    InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, // Apply to subfolders & files
                    PropagationFlags.None, // Keeps inheritance settings intact
                    AccessControlType.Allow
                ));

            // Adjust with the new security settings
            new DirectoryInfo(path).SetAccessControl(security);
        }
        #endregion
    }
}

