// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Xml.Linq;
using Duplicati.Library.Utility;
using Duplicati.Proprietary.DiskImage;
using Duplicati.Proprietary.DiskImage.General;
using NUnit.Framework;

#nullable enable

namespace Duplicati.UnitTest.DiskImage.Helpers;

/// <summary>
/// macOS implementation of <see cref="IDiskImageHelper"/> using hdiutil and standard macOS tools.
/// </summary>
internal class MacOSDiskImageHelper : IDiskImageHelper
{
    /// <inheritdoc />
    public string CreateDisk(string imagePath, long sizeB)
    {
        // Ensure the directory exists
        var directory = Path.GetDirectoryName(imagePath);
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
            Directory.CreateDirectory(directory);

        // Delete existing image if it exists
        if (File.Exists(imagePath))
            try
            {
                CleanupDisk(imagePath);
            }
            catch
            {
                // Ignore errors during cleanup
            }

        // Create a raw disk image using hdiutil
        // Use UDIF format with "Free Space" filesystem (unformatted)
        RunProcess("hdiutil", $"create -size {sizeB} -type UDIF -layout NONE -o \"{imagePath}\"");

        var output = RunProcess("hdiutil", $"attach -nomount -noautofsck \"{imagePath}\"");

        // Parse the disk device from output (e.g., "/dev/disk4")
        var diskDevice = output.Trim().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()
            ?? throw new InvalidOperationException("Failed to get disk device from hdiutil attach output");

        // Return the raw device path for direct I/O
        return diskDevice.Replace("/dev/disk", "/dev/rdisk");
    }

    private string[] GetPartitionNames(string diskIdentifier)
    {
        var output = RunProcess("diskutil", $"list -plist {diskIdentifier}");

        var plist = XDocument.Parse(output);
        var rootDict = plist.Element("plist")?.Element("dict");
        if (rootDict == null)
            return [];

        // Find the disk in AllDisksAndPartitions
        var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
        if (allDisksAndPartitions == null)
            return [];

        // Normalize disk identifier - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
        var normalizedId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");

        var diskElement = allDisksAndPartitions.Elements("dict")
            .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId);

        if (diskElement == null)
            return [];

        var partitions = new List<string>();

        // Check for regular partitions
        var partitionsArray = PlistHelper.GetArrayElement(diskElement, "Partitions");
        if (partitionsArray != null)
        {
            foreach (var partition in partitionsArray.Elements("dict"))
            {
                var deviceIdentifier = PlistHelper.GetStringValue(partition, "DeviceIdentifier");
                if (!string.IsNullOrWhiteSpace(deviceIdentifier))
                    partitions.Add(deviceIdentifier);
            }
        }

        // Check for APFS volumes
        var apfsVolumes = PlistHelper.GetArrayElement(diskElement, "APFSVolumes");
        if (apfsVolumes != null)
        {
            foreach (var volume in apfsVolumes.Elements("dict"))
            {
                var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier");
                if (!string.IsNullOrWhiteSpace(deviceIdentifier))
                    partitions.Add(deviceIdentifier);
            }
        }

        return [.. partitions];
    }

    private string[] GetMountPoints(string diskIdentifier)
    {
        var partitions = GetPartitionNames(diskIdentifier);
        var mountPoints = new System.Collections.Generic.List<string>();

        foreach (var partition in partitions)
        {
            var output = RunProcess("diskutil", $"info -plist /dev/{partition}");
            var plist = XDocument.Parse(output);
            var dict = plist.Element("plist")?.Element("dict");
            if (dict == null)
                continue;

            // Check if this is an APFS Physical Store
            var contentType = PlistHelper.GetStringValue(dict, "Content");
            var isApfsPhysicalStore = contentType == "Apple_APFS";

            if (isApfsPhysicalStore)
            {
                // For APFS, get the container and then get volumes from it
                var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference");
                if (!string.IsNullOrEmpty(containerId))
                {
                    // Get volumes from the container (they are like disk5s1, disk5s2, etc.)
                    var containerVolumes = GetApfsContainerVolumes(containerId);
                    foreach (var volume in containerVolumes)
                    {
                        var volumeOutput = RunProcess("diskutil", $"info -plist {volume}");
                        var volumePlist = XDocument.Parse(volumeOutput);
                        var volumeDict = volumePlist.Element("plist")?.Element("dict");
                        if (volumeDict != null)
                        {
                            var mountPoint = PlistHelper.GetStringValue(volumeDict, "MountPoint");
                            if (!string.IsNullOrEmpty(mountPoint))
                                mountPoints.Add(mountPoint);
                        }
                    }
                }
            }
            else
            {
                // Regular partition - extract mount point directly
                var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint");
                if (!string.IsNullOrEmpty(mountPoint))
                    mountPoints.Add(mountPoint);
            }
        }

        return [.. mountPoints];
    }

    private string[] GetApfsContainerVolumes(string containerId)
    {
        var output = RunProcess("diskutil", $"list -plist {containerId}");

        var plist = XDocument.Parse(output);
        var rootDict = plist.Element("plist")?.Element("dict");
        if (rootDict == null)
            return [];

        var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
        if (allDisksAndPartitions == null)
            return [];

        // Normalize container id - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
        var normalizedId = containerId.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");

        var container = allDisksAndPartitions.Elements("dict")
            .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId);

        if (container == null)
            return [];

        var volumes = new List<string>();
        var apfsVolumes = PlistHelper.GetArrayElement(container, "APFSVolumes");
        if (apfsVolumes != null)
        {
            foreach (var volume in apfsVolumes.Elements("dict"))
            {
                var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier");
                if (!string.IsNullOrWhiteSpace(deviceIdentifier))
                    volumes.Add(deviceIdentifier);
            }
        }

        return [.. volumes];
    }

    /// <inheritdoc />
    public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions)
    {
        if (tableType == PartitionTableType.Unknown)
            return []; // No partition table, so nothing to initialize

        // Use diskutil to partition the disk with the specified scheme
        // "Free Space" means no filesystem, just the partition table
        var partitionStrings = partitions.Length > 0 ? partitions.Select((p, i) =>
        {
            var fsType = p.Item1 switch
            {
                FileSystemType.NTFS => throw new NotSupportedException("NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead."),
                FileSystemType.FAT32 => "\"MS-DOS FAT32\"",
                FileSystemType.ExFAT => "ExFAT",
                FileSystemType.HFSPlus => "HFS+",
                FileSystemType.APFS => "APFS",
                _ => throw new ArgumentException($"Unsupported filesystem type on macOS: {p.Item1}", nameof(partitions))
            };

            var sizeArg = p.Item2 > 0 ? $"{p.Item2}" : "0";
            return $"{fsType} \"Partition {i + 1}\" {sizeArg}";
        })
        : ["\"Free Space\" \"\" 0"];
        var partitionArgs = string.Join(" ", partitionStrings);

        // Normalize disk identifier for diskutil command (it expects "disk4", not "/dev/rdisk4")
        var normalizedDiskId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk");
        RunProcess("diskutil", $"partitionDisk {normalizedDiskId} {tableType.ToString().ToUpperInvariant()} {partitionArgs}");

        if (partitions.Length == 0)
            return [];

        return GetMountPoints(diskIdentifier);
    }

    /// <inheritdoc />
    public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null)
    {
        // Check if already mounted
        try
        {
            var existingMountPoints = GetMountPoints(diskIdentifier);
            if (existingMountPoints.Length > 0 && existingMountPoints.All(mp => mp.StartsWith(baseMountPath ?? "/Volumes/")))
            {
                return existingMountPoints;
            }
        }
        catch
        {
            // Ignore errors and try mounting
        }

        // Get the list of partitions first to ensure we target the correct disk
        var partitions = GetPartitionNames(diskIdentifier);

        List<string> mountPoints = [];
        foreach (var partition in partitions)
        {
            var output = RunProcess("diskutil", $"info -plist /dev/{partition}");
            var plist = XDocument.Parse(output);
            var dict = plist.Element("plist")?.Element("dict");
            if (dict == null)
                continue;

            // Check if this is an APFS Physical Store
            var contentType = PlistHelper.GetStringValue(dict, "Content");
            var isApfsPhysicalStore = contentType == "Apple_APFS";

            if (isApfsPhysicalStore)
            {
                // For APFS, get the container and mount volumes from it
                var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference");
                if (!string.IsNullOrEmpty(containerId))
                {
                    // Get volumes from the container (they are like disk5s1, disk5s2, etc.)
                    var containerVolumes = GetApfsContainerVolumes(containerId);
                    foreach (var volume in containerVolumes)
                    {
                        MountVolume(volume, baseMountPath, mountPoints, readOnly);
                    }
                }
            }
            else
            {
                // Regular partition - mount directly
                MountVolume(partition, baseMountPath, mountPoints, readOnly);
            }
        }

        return mountPoints.Count == 0 ? WaitForMountPoints(diskIdentifier) : [.. mountPoints];
    }

    /// <summary>
    /// Mounts a single volume/partition with optional custom mount point.
    /// </summary>
    /// <param name="volume">The volume identifier (e.g., "disk4s1").</param>
    /// <param name="baseMountPath">Optional base path for mounting.</param>
    /// <param name="mountPoints">List to collect mount points.</param>
    /// <param name="readOnly">Indicates whether the volume should be mounted as read-only.</param>
    private void MountVolume(string volume, string? baseMountPath, List<string> mountPoints, bool readOnly)
    {
        var dir = "";
        var mountArgs = readOnly ? "-readOnly" : "";
        if (baseMountPath is not null)
        {
            dir = Path.Combine(baseMountPath, volume);
            Directory.CreateDirectory(dir); // diskutil will mount to this directory instead of /Volumes/ if it exists, so create it ahead of time
            var readOnlyArg = readOnly ? " -readOnly" : "";
            mountArgs = $"{readOnlyArg} -mountPoint \"{dir}\"";
        }

        // Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location
        var oldMp = GetMountPointFromPlist(RunProcess("diskutil", $"info -plist /dev/{volume}"));
        if (oldMp is not null && !oldMp.StartsWith(baseMountPath ?? "/Volumes/"))
            RunProcess("diskutil", $"unmount force /dev/{volume}");

        RunProcess("diskutil", $"mount -nobrowse {mountArgs} /dev/{volume}");

        // Verify the mount
        var mp = RunProcess("diskutil", $"info -plist /dev/{volume}");
        var mountPoint = GetMountPointFromPlist(mp);
        if (!string.IsNullOrEmpty(mountPoint))
        {
            mountPoints.Add(mountPoint);

            if (!mountPoint.StartsWith(baseMountPath ?? "/Volumes/"))
            {
                Console.WriteLine($"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}");
            }
        }
        else
        {
            Console.WriteLine($"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}");
        }
    }

    /// <summary>
    /// Extracts the mount point from diskutil info -plist output.
    /// </summary>
    private string? GetMountPointFromPlist(string plistOutput)
    {
        try
        {
            var plist = XDocument.Parse(plistOutput);
            var dict = plist.Element("plist")?.Element("dict");
            if (dict == null)
                return null;

            var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint");
            // Return null if not mounted (empty string indicates not mounted)
            return string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint;
        }
        catch
        {
            return null;
        }
    }

    /// <summary>
    /// Waits for mount points to be fully established after a mount operation.
    /// Uses exponential backoff starting from a short delay to minimize wait time
    /// in the common case while still handling slow mounts.
    ///
    /// After mounting (especially after raw partition table writes during restore),
    /// macOS may need time to fully recognize filesystems and establish mount points
    /// in /Volumes/. Without this retry loop, mount points may be:
    /// - Not yet available (empty array)
    /// - In a transient location like /private/var/ instead of /Volumes/
    /// - Mounted but with empty directories (filesystem not yet recognized)
    /// </summary>
    /// <param name="diskIdentifier">The disk identifier to check mount points for.</param>
    /// <returns>An array of mount point paths.</returns>
    private string[] WaitForMountPoints(string diskIdentifier)
    {
        const int maxRetries = 10;
        const int initialDelayMs = 100;
        const int maxDelayMs = 1000;

        var delayMs = initialDelayMs;
        for (int i = 0; i < maxRetries; i++)
        {
            var mountPoints = GetMountPoints(diskIdentifier);

            if (mountPoints.Length > 0)
            {
                // Check if any mount points are in a transient location (not under /Volumes/)
                if (mountPoints.Any(mp => !mp.StartsWith("/Volumes/")))
                {
                    Thread.Sleep(delayMs);
                    delayMs = Math.Min(delayMs * 2, maxDelayMs);
                    continue;
                }

                // Check if all mount points have filesystem entries (not empty)
                var allNonEmpty = mountPoints.All(mp =>
                {
                    try { return Directory.GetFileSystemEntries(mp).Length > 0; }
                    catch { return false; }
                });

                if (allNonEmpty || i == maxRetries - 1)
                    return mountPoints;
            }

            Thread.Sleep(delayMs);
            delayMs = Math.Min(delayMs * 2, maxDelayMs);
        }

        // Final attempt - return whatever we have
        return GetMountPoints(diskIdentifier);
    }

    public void Unmount(string diskIdentifier)
    {
        int retryCount = 3;
        for (int i = 0; i < retryCount; i++)
        {
            try
            {
                // Unmount all volumes on the disk
                RunProcess("diskutil", $"unmountDisk {diskIdentifier}");
            }
            catch
            {
                // Sometimes an unmount is issued too fast after writing data, which makes diskutil throw an errory.
                Thread.Sleep(500);
            }
        }
    }

    /// <inheritdoc />
    public void FlushDisk(string diskIdentifier)
    {
        // On macOS, use sync to flush all filesystem buffers
        RunProcess("sync", "");
    }

    /// <inheritdoc />
    public void CleanupDisk(string imagePath, string? diskIdentifier = null)
    {
        if (diskIdentifier is null)
        {
            var output = RunProcess("hdiutil", $"info");

            diskIdentifier = output
                .Split("================================================")
                .Skip(1) // Skip the header
                .Select(d =>
                {
                    var lines = d
                        .Split('\n')
                        .Select(l => l.Trim())
                        .Where(l => l.Length > 0)
                        .ToList();
                    var imagePathLine = lines.FirstOrDefault(l => l.StartsWith("image-path"));
                    if (imagePathLine == null)
                        return null;

                    var path = imagePathLine.Split([':'], 2)[1].Trim();
                    if (!string.Equals(path, imagePath, StringComparison.OrdinalIgnoreCase))
                        return null;

                    var deviceLine = lines.FirstOrDefault(l => l.StartsWith("/dev/"));
                    if (deviceLine == null)
                        return null;

                    var device = deviceLine
                        .Split(['\t'], 2)[0]
                        .Trim();

                    return device;
                })
                .FirstOrDefault(d => d != null);
        }

        if (diskIdentifier is null)
            throw new InvalidOperationException($"Failed to determine disk identifier for {imagePath} during cleanup");

        try
        {
            RunProcess("diskutil", $"eject {diskIdentifier}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Warning: Failed to detach disk image: {ex.Message}");
        }

        try
        {
            if (File.Exists(imagePath))
                File.Delete(imagePath);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}");
        }
    }

    /// <inheritdoc />
    public bool HasRequiredPrivileges()
    {
        // Root is not required for virtual disks.
        return true;
    }

    /// <inheritdoc />
    public PartitionTableGeometry GetPartitionTable(string diskIdentifier)
    {
        var output = RunProcess("diskutil", $"info -plist {diskIdentifier}");

        var plist = XDocument.Parse(output);
        var dict = plist.Element("plist")?.Element("dict");
        if (dict == null)
            throw new InvalidOperationException($"Failed to parse diskutil info plist for {diskIdentifier}");

        // Verify disk identifier
        var deviceIdentifier = PlistHelper.GetStringValue(dict, "DeviceIdentifier");
        if (!diskIdentifier.EndsWith(deviceIdentifier ?? "", StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException($"Disk identifier mismatch in diskutil info output: {diskIdentifier} doesn't end with {deviceIdentifier}");

        // Determine partition table type
        var contentType = PlistHelper.GetStringValue(dict, "Content");
        var tableType = contentType switch
        {
            "GUID_partition_scheme" => PartitionTableType.GPT,
            "FDisk_partition_scheme" => PartitionTableType.MBR,
            _ => PartitionTableType.Unknown
        };

        // Get size and sector size
        var size = PlistHelper.GetLongValue(dict, "TotalSize");
        var sectorSize = (int)PlistHelper.GetLongValue(dict, "DeviceBlockSize");

        if (size == 0 || sectorSize == 0)
            throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}");

        return new PartitionTableGeometry
        {
            Type = tableType,
            Size = size,
            SectorSize = sectorSize
            // Ignore the rest for now.
        };
    }

    /// <inheritdoc />
    public PartitionGeometry[] GetPartitions(string diskIdentifier)
    {
        var names = GetPartitionNames(diskIdentifier);

        var partitions = new List<PartitionGeometry>();
        foreach (var name in names)
        {
            var output = RunProcess("diskutil", $"info -plist /dev/{name}");

            var plist = XDocument.Parse(output);
            var dict = plist.Element("plist")?.Element("dict");
            if (dict == null)
                continue;

            // Verify device node
            var deviceNode = PlistHelper.GetStringValue(dict, "DeviceNode");
            if (!string.Equals(deviceNode, $"/dev/{name}", StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException($"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {deviceNode}");

            // Extract partition number from name (e.g., "disk4s1" -> 1)
            var partitionNumber = int.TryParse(
                name.LastIndexOf('s') >= 0 ? name[(name.LastIndexOf('s') + 1)..] : "",
                out int num) ? num : -1;

            // Parse filesystem type
            var fsPersonality = PlistHelper.GetStringValue(dict, "FilesystemPersonality");
            var filesystemType = fsPersonality switch
            {
                "MS-DOS FAT32" => FileSystemType.FAT32,
                "ExFAT" => FileSystemType.ExFAT,
                "HFS+" => FileSystemType.HFSPlus,
                "APFS" => FileSystemType.APFS,
                _ => FileSystemType.Unknown
            };

            // Parse volume UUID
            Guid? volumeGuid = null;
            var volumeUuid = PlistHelper.GetStringValue(dict, "VolumeUUID");
            if (Guid.TryParse(volumeUuid, out Guid parsedGuid))
                volumeGuid = parsedGuid;

            PartitionGeometry partition = new()
            {
                Number = partitionNumber,
                Type = PartitionType.Unknown,
                StartOffset = PlistHelper.GetLongValue(dict, "PartitionMapPartitionOffset"),
                Size = PlistHelper.GetLongValue(dict, "TotalSize"),
                Name = PlistHelper.GetStringValue(dict, "VolumeName"),
                FilesystemType = filesystemType,
                VolumeGuid = volumeGuid,
                TableType = PartitionTableType.Unknown
            };

            partitions.Add(partition);
        }

        return partitions.ToArray();
    }

    public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false)
    {
        // Eject can fail if issued too fast after writing data, which makes diskutil throw an error. Retry a few times with a delay to mitigate this.
        RunProcess("diskutil", $"eject {diskIdentifier}", retryCount: 3);
        var readOnlyArg = readOnly ? " -readonly" : "";
        var reattachoutput = RunProcess("hdiutil", $"attach -nomount -noautofsck{readOnlyArg} \"{imagePath}\"");
        if (tableType == PartitionTableType.Unknown)
            return reattachoutput.Split('\n').First().Trim();
        else
            return reattachoutput
                .Split('\n')
                .Select(x => x.Trim()
                    .Split(' ')
                    .Where(y => !string.IsNullOrEmpty(y))
                    .Select(y => y.Trim())
                    .ToArray()
                )
                .Where(x => x.Length > 0 && x[^1].EndsWith("_partition_scheme"))
                .First().First();
    }

    public static string RunProcess(string fileName, string arguments, int retryCount = 0)
    {
        for (int i = 0; i <= retryCount; i++)
        {
            var result = ProcessRunner.RunProcessAsync(fileName, arguments, 30_000).Await();


            if (result.ExitCode != 0)
                if (i < retryCount)
                {
                    Console.WriteLine($"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}");
                    Thread.Sleep(500);
                    continue;
                }
                else
                    throw new InvalidOperationException($"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}");

            return result.Output;
        }

        // This should never be hit due to the retry loop logic, but is required to satisfy the compiler.
        throw new InvalidOperationException($"Failed to run process {fileName} with arguments {arguments} after retries. This should not happen.");
    }

    /// <inheritdoc />
    public void Dispose()
    {
        // Nothing to dispose
        GC.SuppressFinalize(this);
    }

}
