// 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 Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Assert = NUnit.Framework.Legacy.ClassicAssert;

#nullable enable

namespace Duplicati.UnitTest
{

    /// <summary>
    /// Tests the tools.
    /// </summary>
    public class ToolTests : BasicSetupHelper
    {

        /// <summary>
        /// Tests that the remote synchronization tool doesn't do anything when the dry run option is used.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestDryRun()
        {
            var l1 = Path.Combine(TARGETFOLDER, "l1");
            var l2 = Path.Combine(TARGETFOLDER, "l2");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            GenerateTestData(l1, 5, 0, 0, 1024).Wait();

            var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm", "--dry-run" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0.");
            Assert.IsFalse(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are equal");
            Assert.IsTrue(!Directory.EnumerateFiles(l2).Any(), "Destination directory is not empty");
        }

        /// <summary>
        /// Tests that the remote synchronization tool works with an empty source to an empty destination.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestEmptySourceAndDestination()
        {
            var l1 = Path.Combine(TARGETFOLDER, "empty_src");
            var l2 = Path.Combine(TARGETFOLDER, "l2");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0.");
            Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal");
        }

        /// <summary>
        /// Test that remote synchronizing an empty source to a non-empty destination deletes the destination files.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestEmptySourceDeletesDestination()
        {
            var l1 = Path.Combine(TARGETFOLDER, "empty_src");
            var l2 = Path.Combine(TARGETFOLDER, "l2");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            GenerateTestData(l2, 5, 0, 0, 1024).Wait();

            var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0.");
            Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal");
        }

        /// <summary>
        /// Test that remote synchronizing an empty source to a non-empty destination renames the destination files when the `--retention` option is used.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestEmptySourceRenamesDestination()
        {
            var l1 = Path.Combine(TARGETFOLDER, "empty_src");
            var l2 = Path.Combine(TARGETFOLDER, "l2");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            GenerateTestData(l2, 5, 0, 0, 1024).Wait();

            var filelist = Directory.EnumerateFiles(l2).ToList();
            var files = filelist.Select(x => File.ReadAllBytes(x)).ToList();

            var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm", "--retention" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0.");

            var newfilelist = Directory.EnumerateFiles(l2).ToList();
            foreach (var (name, contents) in filelist.Zip(files))
            {
                var filename = Path.GetFileName(name);
                var newfilename = newfilelist.FirstOrDefault(x => x.EndsWith(filename));
                if (newfilename == null)
                {
                    Assert.Fail($"File {filename} was not renamed in the destination directory.");
                }
                else
                {
                    var newcontents = File.ReadAllBytes(newfilename);
                    Assert.AreEqual(contents, newcontents, "File contents are not equal");
                }
            }
        }

        [Test]
        public void TestFullResultOptionSupportsAliasAndConsoleOutput()
        {
            var option = new Options(new Dictionary<string, string?>()).SupportedCommands.FirstOrDefault(x => x.Name == "full-result");

            Assert.IsNotNull(option, "The full-result option was not registered.");
            Assert.IsTrue(option!.Aliases.Contains("full-results"), "The full-results alias was not registered.");

            using var writer = new StringWriter();
            using var singularConsole = new CommandLine.ConsoleOutput(writer, new Dictionary<string, string> { ["full-result"] = "true" });
            using var pluralConsole = new CommandLine.ConsoleOutput(writer, new Dictionary<string, string> { ["full-results"] = "true" });

            Assert.IsTrue(singularConsole.FullResults, "Console output did not honor the full-result option.");
            Assert.IsTrue(pluralConsole.FullResults, "Console output did not honor the full-results alias.");
        }

        /// <summary>
        /// Tests passing all arguments to the main method of the remote synchronization tool.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestMainMethodParsesArgumentsCorrectly()
        {
            string[][] testCases =
            {
                ["source", "destination", "--parse-arguments-only"],
                ["source", "destination", "--parse-arguments-only", "--auto-create-folders"],
                ["source", "destination", "--parse-arguments-only", "--backend-retries", "5"],
                ["source", "destination", "--parse-arguments-only", "--backend-retry-delay", "1000"],
                ["source", "destination", "--parse-arguments-only", "--backend-retry-with-exponential-backoff"],
                ["source", "destination", "--parse-arguments-only", "--dry-run"],
                ["source", "destination", "--parse-arguments-only", "--force"],
                ["source", "destination", "--parse-arguments-only", "--dry-run", "--force"],
                ["source", "destination", "--parse-arguments-only", "--verify-contents"],
                ["source", "destination", "--parse-arguments-only", "--verify-get-after-put"],
                ["source", "destination", "--parse-arguments-only", "--retry", "3"],
                ["source", "destination", "--parse-arguments-only", "--log-level", "Debug"],
                ["source", "destination", "--parse-arguments-only", "--log-level", "Information"],
                ["source", "destination", "--parse-arguments-only", "--log-level", "Profiling"],
                ["source", "destination", "--parse-arguments-only", "--log-level", "Verbose"],
                ["source", "destination", "--parse-arguments-only", "--log-file", "somefile.log"],
                ["source", "destination", "--parse-arguments-only", "--progress"],
                ["source", "destination", "--parse-arguments-only", "--retention"],
                ["source", "destination", "--parse-arguments-only", "--confirm"],
                ["source", "destination", "--parse-arguments-only", "--global-options", "someglobalkey=someglobalvalue", "anotherglobalkey=anotherglobalvalue"],
                ["source", "destination", "--parse-arguments-only", "--src-options", "somesrckey=somesrcvalue", "anothersrckey=anothersrcvalue"],
                ["source", "destination", "--parse-arguments-only", "--dst-options", "somedstkey=somedstvalue", "anotherdstkey=anotherdstvalue"],
                [
                    "source", "destination", "--parse-arguments-only",
                    "--dry-run", "--force", "--verify-contents", "--retry", "3", "--log-level", "Debug", "--log-file", "somefile.log", "--progress", "--retention", "--confirm",
                    "--global-options", "someglobalkey=someglobalvalue", "anotherglobalkey=anotherglobalvalue",
                    "--src-options", "somesrckey=somesrcvalue", "anothersrckey=anothersrcvalue",
                    "--dst-options", "somedstkey=somedstvalue", "anotherdstkey=anotherdstvalue"
                ],
                [
                    "source", "destination", "--parse-arguments-only",
                    "--global-options", "somekey=somevalue=with=extra=equals", "anotherkey=anothervalue",
                    "--src-options", "somekey=somevalue=with=extra=equals", "anotherkey=anothervalue",
                    "--dst-options", "somekey=somevalue=with=extra=equals", "anotherkey=anothervalue"
                ],
                [
                    "source", "destination", "--parse-arguments-only",
                    "--global-options", "somekey=\"some value with spaces\"", "anotherkey=anothervalue",
                    "--src-options", "somekey=\"some value with spaces\"", "anotherkey=anothervalue",
                    "--dst-options", "somekey=\"some value with spaces\"", "anotherkey=anothervalue"
                ]
            };

            foreach (var args in testCases)
            {
                int result = RemoteSynchronization.Program.Main(args).ConfigureAwait(false).GetAwaiter().GetResult();
                Assert.AreEqual(0, result, $"Failed for args: {string.Join(" ", args)}");
            }

            int failed_result = RemoteSynchronization.Program.Main(["source", "destination", "--bogus-option"]).ConfigureAwait(false).GetAwaiter().GetResult();
            Assert.AreEqual(1, failed_result, "Invalid option did not return 1");
        }

        /// <summary>
        /// Tests the original inded use of the remote synchronization tool on an empty destination.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestRemoteSynchronization()
        {
            var l1 = Path.Combine(TARGETFOLDER, "l1");
            var l2 = Path.Combine(TARGETFOLDER, "l2");
            var l1r = Path.Combine(RESTOREFOLDER, "l1_restore");
            var l2r = Path.Combine(RESTOREFOLDER, "l2_restore");

            var options = TestOptions;

            var now = DateTime.Now;
            GenerateTestData(DATAFOLDER, 5, 2, 2, 1024).Wait();
            Console.WriteLine($"Generated test data in {DATAFOLDER} in {DateTime.Now - now}");

            // Create the directories if they do not exist
            foreach (var p in new string[] { l1, l2, l1r, l2r })
            {
                if (!SystemIO.IO_OS.DirectoryExists(p))
                    SystemIO.IO_OS.DirectoryCreate(p);
            }

            // Backup the first level
            using (var c = new Controller($"file://{l1}", options, null))
            {
                now = DateTime.Now;
                var results = c.Backup([DATAFOLDER]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Backed up {results.AddedFiles} files to {l1} in {DateTime.Now - now}");
            }

            // Call the tool
            now = DateTime.Now;
            var exe = RemoteSynchronization.Program.Main;
            string[] args = [
                $"file://{l1}", $"file://{l2}",
                "--global-options", ..options.Select(x => $"{x.Key}={x.Value}"),
                // Pass along multi token options to test that the parser won't fail
                "--src-options", "ssh-accept-any-fingerprints=true", "ssh-keyfile=/path/to/keyfile",
                "--dst-options", "some-other-key=value", "another-key=value2",
                "--confirm"
            ];
            var async_call = exe(args);
            async_call.Wait();
            Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0.");
            Console.WriteLine($"Remote synchronization tool returned 0 in {DateTime.Now - now}");

            // Verify that the directories are equal
            Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal");

            // Try to restore the first level
            options["restore-path"] = l1r;
            using (var c = new Controller($"file://{l1}", options, null))
            {
                now = DateTime.Now;
                var results = c.Restore([]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}");
            }
            Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l1r), "Restored first level files is not equal to original files");

            // Try to restore the second level
            options["restore-path"] = l2r;
            using (var c = new Controller($"file://{l2}", options, null))
            {
                now = DateTime.Now;
                var results = c.Restore([]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}");
            }
            Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files");

            // Delete the l2r directory
            SystemIO.IO_OS.DirectoryDelete(l2r, true);

            // Delete one file from the l2 backup
            var files = Directory.EnumerateFiles(l2).ToList();
            File.Delete(files.First());

            // Check that the restore fails
            options["restore-path"] = l2r;
            using (var c = new Controller($"file://{l2}", options, null))
            {
                now = DateTime.Now;
                try
                {
                    var results = c.Restore([]);
                }
                catch (RemoteListVerificationException)
                {
                    Console.WriteLine($"Failed (as expected) to restore files to {options["restore-path"]} in {DateTime.Now - now}");
                }
            }

            // Run the tool again to copy the missing file
            now = DateTime.Now;
            async_call = exe(args);
            async_call.Wait();
            Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0.");
            Console.WriteLine($"Remote synchronization tool returned 0 in {DateTime.Now - now}");

            // Try to restore the second level again
            options["restore-path"] = l2r;
            using (var c = new Controller($"file://{l2}", options, null))
            {
                now = DateTime.Now;
                var results = c.Restore([]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}");
            }

            Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files");

            // Add some more files to the source
            GenerateTestData(Path.Combine(DATAFOLDER, "brand_new_files"), 5, 2, 2, 1024).Wait();

            // Backup the new files to l1
            using (var c = new Controller($"file://{l1}", options, null))
            {
                now = DateTime.Now;
                var results = c.Backup([DATAFOLDER]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Backed up {results.AddedFiles} files to {l1} in {DateTime.Now - now}");
            }

            // Run the tool again to copy the new files
            now = DateTime.Now;
            async_call = exe(args);
            async_call.Wait();
            Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0.");
            Console.WriteLine($"Remote synchronization tool returned 0 in {DateTime.Now - now}");

            // Try to restore the second level again
            options["restore-path"] = l2r;
            using (var c = new Controller($"file://{l2}", options, null))
            {
                now = DateTime.Now;
                var results = c.Restore([]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}");
            }

            Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files");

            // Delete the l2r directory
            SystemIO.IO_OS.DirectoryDelete(l2r, true);

            // Remove a directory from the source
            SystemIO.IO_OS.DirectoryDelete(Path.Combine(DATAFOLDER, "dir_0"), true);

            // Backup the new files to l1
            using (var c = new Controller($"file://{l1}", options, null))
            {
                now = DateTime.Now;
                var results = c.Backup([DATAFOLDER]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Backed up {results.AddedFiles} files to {l1} in {DateTime.Now - now}");
            }

            // Compact the backup
            using (var c = new Controller($"file://{l1}", options, null))
            {
                now = DateTime.Now;
                var results = c.Compact();
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Compacted backup in {DateTime.Now - now}");
            }

            // Run the tool again to copy the new files
            now = DateTime.Now;
            async_call = exe(args);
            async_call.Wait();
            Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0.");

            // Try to restore the second level again
            options["restore-path"] = l2r;
            using (var c = new Controller($"file://{l2}", options, null))
            {
                now = DateTime.Now;
                var results = c.Restore([]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}");
            }

            Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files");

            // Delete the l2r directory
            SystemIO.IO_OS.DirectoryDelete(l2r, true);

            // Perform a forced synchronization with retention to check that retention doesn't break a restore
            now = DateTime.Now;
            async_call = exe([.. args, "--force", "--retention"]);
            async_call.Wait();
            Assert.AreEqual(0, async_call.Result, "Remote synchronization tool did not return 0.");

            // Try to restore the second level again
            options["restore-path"] = l2r;
            using (var c = new Controller($"file://{l2}", options, null))
            {
                now = DateTime.Now;
                var results = c.Restore([]);
                Assert.AreEqual(0, results.Errors.Count());
                Assert.AreEqual(0, results.Warnings.Count());
                Console.WriteLine($"Restored {results.RestoredFiles} files to {options["restore-path"]} in {DateTime.Now - now}");
            }

            Assert.IsTrue(DirectoriesAndContentsAreEqual(DATAFOLDER, l2r), "Restored second level files is not equal to original files");
        }

        [Test]
        [Category("Tools/RemoteSynchronization")]
        [TestCase(true, false, "0")] // Fail first transfer on source.
        [TestCase(true, false, "1,3")] // Fail middle transfers on source.
        [TestCase(true, false, "4")] // Fail last transfer on source.
        [TestCase(true, false, "0,1,2,3,4")] // Fail all transfers on source.
        [TestCase(false, true, "0")] // Fail first transfer on destination.
        [TestCase(false, true, "1,3")] // Fail middle transfers on destination.
        [TestCase(false, true, "4")] // Fail last transfer on destination.
        [TestCase(false, true, "0,1,2,3,4")] // Fail all transfers on destination.
        public void TestRemoteSynchronizationWithFaultyBackend(bool failSource, bool failDest, string failIndices)
        {
            var expect_to_fail = failIndices == "0,1,2,3,4";

            var l1 = Path.Combine(TARGETFOLDER, "l1");
            var l2 = Path.Combine(TARGETFOLDER, "l2");
            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            int backendCount = 0;
            var failIdxs = failIndices.Split(',').Select(int.Parse).ToList();
            Library.DynamicLoader.BackendLoader.AddBackend(new DeterministicErrorBackend());
            DeterministicErrorBackend.ErrorGenerator = (action, remotename) =>
            {
                if (action == DeterministicErrorBackend.BackendAction.GetBefore || action == DeterministicErrorBackend.BackendAction.PutBefore)
                {
                    var currentIdx = backendCount;
                    if (failIdxs.Count > 0 && currentIdx == failIdxs.First())
                    {
                        failIdxs.RemoveAt(0);
                        return true; // Simulate failure
                    }
                    else
                    {
                        backendCount++;
                    }
                }

                return false; // No failure
            };

            GenerateTestData(l1, 5, 0, 0, 1024).Wait();

            // Setup backend URLs with failure injection
            var protocol = new DeterministicErrorBackend().ProtocolKey;
            string srcUrl = failSource ? $"{protocol}://{l1}" : $"file://{l1}";
            string dstUrl = failDest ? $"{protocol}://{l2}" : $"file://{l2}";

            var args = new string[] {
                srcUrl, dstUrl,
                "--confirm",
                "--backend-retries", expect_to_fail ? "0" : "5",
                "--backend-retry-delay", "10",
                "--retry", "0" // This test only tests the LightWeightBackendManager's retry logic.
            };

            // Redirect standard error to a buffer if we expect to fail. If it doesn't fail, we can omit it.
            var originalError = Console.Error;
            StringWriter? errorBuffer = null;
            if (expect_to_fail)
            {
                errorBuffer = new StringWriter();
                Console.SetError(errorBuffer);
            }

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            // Expect nonzero return code if any backend fails less than the number of retries
            if (expect_to_fail)
            {
                try
                {
                    Assert.AreNotEqual(0, return_code, "Expected failure due to all transfers failing.");
                    Assert.IsFalse(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories should not be equal due to failures.");
                }
                catch
                {
                    Console.SetError(originalError); // Restore standard error
                    Console.Error.WriteLine(errorBuffer?.ToString());
                    throw;
                }
            }
            else
            {
                Assert.AreEqual(0, return_code, "Expected success.");
                Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal.");
            }
        }

        /// <summary>
        /// Tests that the remote synchronization tool verifies the contents of the files.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestVerifies()
        {
            var l1 = Path.Combine(TARGETFOLDER, "l1");
            var l2 = Path.Combine(TARGETFOLDER, "l2");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            GenerateTestData(l1, 5, 0, 0, 1024).Wait();

            var filenames = Directory.EnumerateFiles(l1).Take(2).ToList();
            var first_file = filenames.First();
            var second_file = filenames.Skip(1).First();

            File.Copy(first_file, Path.Combine(l2, Path.GetFileName(first_file)));
            File.Copy(second_file, Path.Combine(l2, Path.GetFileName(second_file)));

            // Touch the first file to give it a different timestamp
            File.SetLastWriteTime(first_file, DateTime.Now.AddMinutes(1));

            // Modify the second file to give it different contents, but the same size
            var second_file_contents = File.ReadAllBytes(second_file);
            second_file_contents[0] = (byte)(second_file_contents[0] + 1);
            File.WriteAllBytes(second_file, second_file_contents);

            var args = new string[] { $"file://{l1}", $"file://{l2}", "--confirm", "--verify-contents", "--verify-get-after-put" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreEqual(0, return_code, "Remote synchronization tool did not return 0.");
            Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Synchronized directories are not equal");
        }

        //
        // Helper methods
        //

        /// <summary>
        /// Compares two directories and their contents.
        /// </summary>
        /// <param name="d1">The first directory.</param>
        /// <param name="d2">The second directory.</param>
        /// <returns>`true` if the two directories contain exactly the same files and if the contents of the files are equivalent. `false` otherwise.</returns>
        public static bool DirectoriesAndContentsAreEqual(string d1, string d2)
        {
            // Recursively get the files in the two directories
            var f1s = Directory.EnumerateFiles(d1, "*", SearchOption.AllDirectories).Select(x => x[(d1.Length + 1)..]).OrderDescending();
            var f2s = Directory.EnumerateFiles(d2, "*", SearchOption.AllDirectories).Select(x => x[(d2.Length + 1)..]).OrderDescending();

            // If the two directories do not contain the same files, return false
            if (!f1s.SequenceEqual(f2s))
                return false;

            // Check that each file pair exist and have the same content
            foreach (var f1 in f1s)
            {
                var f1full = Path.Combine(d1, f1);
                if (!File.Exists(f1full))
                    return false;

                var f2full = Path.Combine(d2, f1);
                if (!File.Exists(f2full))
                    return false;

                var c1 = File.ReadAllText(f1full);
                var c2 = File.ReadAllText(f2full);

                if (!c1.SequenceEqual(c2))
                    return false;
            }

            return true;
        }

        /// <summary>
        /// Generates test data in the specified directory.
        /// </summary>
        /// <param name="dir">The directory to fill with the generated data.</param>
        /// <param name="n_files">How many files the directory should have.</param>
        /// <param name="n_dirs">How many subdirectories the directory should have.</param>
        /// <param name="n_levels">How deep the number of subdirectories within subdirectories should go.</param>
        /// <param name="max_file_size">The maximum size of the files to generate.</param>
        public static async Task GenerateTestData(string dir, int n_files, int n_dirs, int n_levels, int max_file_size)
        {
            if (!SystemIO.IO_OS.DirectoryExists(dir))
                SystemIO.IO_OS.DirectoryCreate(dir);

            var fs = Enumerable.Range(0, n_files)
                .Select(i => GenerateTestFile(dir, i, max_file_size));
            var ds = n_levels > 0 ?
                Enumerable.Range(0, n_dirs)
                    .Select(i =>
                    {
                        var subdir = Path.Combine(dir, $"dir_{i}");
                        return GenerateTestData(subdir, n_files, n_dirs, n_levels - 1, max_file_size);
                    })
                : [];

            await Task.WhenAll([.. fs, .. ds]);
        }

        public static async Task GenerateTestFile(string dir, int i, int max_file_size)
        {
            var rnd = new Random();
            var file = Path.Combine(dir, $"file_{i}.txt");
            var size = rnd.Next(1, max_file_size);
            var data = new byte[size];
            rnd.NextBytes(data);
            await File.WriteAllBytesAsync(file, data);
        }

        /// <summary>
        /// A test backend that simulates a quota of 1KB for testing quota checks in the remote synchronization tool.
        /// </summary>
        public class FileBackendWith1Kb : Library.Backend.File
        {
            public FileBackendWith1Kb() { }

            public FileBackendWith1Kb(string url, Dictionary<string, string?> options) : base(url.Replace("quotatest://", "file://"), options) { }

            public override string ProtocolKey => "quotatest";

            public override Task<IQuotaInfo?> GetQuotaInfoAsync(CancellationToken token)
            {
                return Task.FromResult<IQuotaInfo?>(new QuotaInfo(1024 * 1024, 1024));
            }
        }

        /// <summary>
        /// Tests that the remote synchronization tool checks quota and fails if insufficient.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestRemoteSynchronizationQuotaCheckFails()
        {
            var l1 = Path.Combine(TARGETFOLDER, "quota_src");
            var l2 = Path.Combine(TARGETFOLDER, "quota_dst");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            // Generate test data: 5 files of ~1KB each, total ~5KB
            GenerateTestData(l1, 5, 0, 0, 2048).Wait();

            // Register the test backend
            Library.DynamicLoader.BackendLoader.AddBackend(new FileBackendWith1Kb());

            // Use the test backend for destination with only 1KB free quota
            var args = new string[] { $"file://{l1}", $"quotatest://{l2}", "--confirm" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreNotEqual(0, return_code, "Remote synchronization should fail due to insufficient quota.");
        }

        /// <summary>
        /// Tests that the remote synchronization tool succeeds when quota is sufficient.
        /// </summary>
        [Test]
        [Category("Tools/RemoteSynchronization")]
        public void TestRemoteSynchronizationQuotaCheckSucceeds()
        {
            var l1 = Path.Combine(TARGETFOLDER, "quota_src2");
            var l2 = Path.Combine(TARGETFOLDER, "quota_dst2");

            Directory.CreateDirectory(l1);
            Directory.CreateDirectory(l2);

            // Generate small test data: 1 file of ~500B
            GenerateTestData(l1, 1, 0, 0, 512).Wait();

            // Register the test backend
            Library.DynamicLoader.BackendLoader.AddBackend(new FileBackendWith1Kb());

            // Use the test backend for destination with 1KB free quota (sufficient)
            var args = new string[] { $"file://{l1}", $"quotatest://{l2}", "--confirm", "--dst-options", "freequota=1024" };

            var async_call = RemoteSynchronization.Program.Main(args);
            var return_code = async_call.ConfigureAwait(false).GetAwaiter().GetResult();

            Assert.AreEqual(0, return_code, "Remote synchronization should succeed with sufficient quota.");
            Assert.IsTrue(DirectoriesAndContentsAreEqual(l1, l2), "Directories should be synchronized.");
        }

    }
}