// 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.CommandLine;
using System.CommandLine.NamingConventionBinder;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Web;
using Duplicati.Library.AutoUpdater;

namespace ReleaseBuilder.Build;

/// <summary>
/// The build command implementation
/// </summary>
public static partial class Command
{
    /// <summary>
    /// The primary project to build for GUI builds
    /// </summary>
    private const string PrimaryGUIProject = "Duplicati.GUI.TrayIcon.csproj";

    /// <summary>
    /// The primary project to build for CLI builds
    /// </summary>
    private const string PrimaryCLIProject = "Duplicati.Server.csproj";

    /// <summary>
    /// The primary project to build for Agent builds
    /// </summary>
    private const string PrimaryAgentProject = "Duplicati.Agent.csproj";

    /// <summary>
    /// Projects that only makes sense for Windows
    /// </summary>
    private static readonly IReadOnlySet<string> WindowsOnlyProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) {
        "Duplicati.WindowsService.csproj",
        "Duplicati.WindowsModulesLoader.csproj",
        "Duplicati.ShellExtension.csproj"
    };

    /// <summary>
    /// Projects the pull in GUI dependencies
    /// </summary>
    private static readonly IReadOnlySet<string> GUIOnlyProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.GUI.TrayIcon.csproj" };

    /// <summary>
    /// Projects that are Agent only
    /// </summary>
    private static readonly IReadOnlySet<string> AgentOnlyProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.Agent.csproj" };

    /// <summary>
    /// Projects that are built for support reasons but where ouput is not used in the final build
    /// </summary>
    private static readonly IReadOnlySet<string> EphemeralProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) {
        "Duplicati.WindowsModulesLoader.csproj", 
        // Shell extensions are not ephemeral, but does not produce an .exe output
        "Duplicati.ShellExtension.csproj"
    };

    /// <summary>
    /// Projects that are always excluded from builds
    /// </summary>
    private static readonly IReadOnlySet<string> ExcludedProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { };

    /// <summary>
    /// The projects that are part of the agent builds
    /// </summary>
    private static readonly IReadOnlySet<string> AgentProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) {
        "Duplicati.Agent.csproj",
        "Duplicati.Service.csproj",
        "Duplicati.WindowsModulesLoader.csproj",
        "Duplicati.WindowsService.csproj"
    };

    /// <summary>
    /// The packages that require Docker to build
    /// </summary>
    private static readonly IReadOnlySet<PackageType> DockerDependingTargets = new HashSet<PackageType> {
        PackageType.Docker,
        PackageType.Deb,
        PackageType.RPM,
        PackageType.AppImage
    };

    /// <summary>
    /// Some executables have shorter names that follow the Linux convention of all-lowercase
    /// </summary>
    /// <remarks>Note that the values here mirror the values in the AutoUpdater.PackageHelper, so changes should be coordinated between the two</remarks>
    private static readonly IDictionary<string, string> ExecutableRenames = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
    {
        { "Duplicati.CommandLine.BackendTester", "duplicati-backend-tester"},
        { "Duplicati.CommandLine.BackendTool", "duplicati-backend-tool" },
        { "Duplicati.CommandLine.RecoveryTool", "duplicati-recovery-tool" },
        { "Duplicati.CommandLine.AutoUpdater", "duplicati-autoupdater" },
        { "Duplicati.CommandLine.SharpAESCrypt", "duplicati-aescrypt" },
        { "Duplicati.CommandLine.Snapshots", "duplicati-snapshots" },
        { "Duplicati.CommandLine.ServerUtil", "duplicati-server-util" },
        { "Duplicati.CommandLine.SecretTool", "duplicati-secret-tool" },
        { "Duplicati.CommandLine.SyncTool", "duplicati-sync-tool" },
        { "Duplicati.CommandLine.SourceTool", "duplicati-source-tool" },
        { "Duplicati.CommandLine.DatabaseTool", "duplicati-database-tool" },
        { "Duplicati.CommandLine.ConfigureTool", "duplicati-configure" },
        { "Duplicati.Service", "duplicati-service" },
        { "Duplicati.Agent", "duplicati-agent" },
        { "Duplicati.CommandLine", "duplicati-cli" },
        { "Duplicati.Server", "duplicati-server"},
        { "Duplicati.GUI.TrayIcon", "duplicati" }
    };

    /// <summary>
    /// The supported versions of libicu for Debian
    /// </summary>
    private static string DebianLibIcuVersions => "libicu | " + string.Join(" | ",
        "80;79;78;77;76;75;74;73;72;71;70;69;68;67"
        .Split(";", StringSplitOptions.RemoveEmptyEntries)
        .Select(x => $"libicu{x}"));

    /// <summary>
    /// The supported versions of libssl for Debian
    /// </summary>
    private static string DebianLibSslVersions => "libssl3 | libssl1.1 | libssl3t64";

    /// <summary>
    /// The packages that are required for GUI builds
    /// </summary>
    private static readonly IReadOnlyList<string> DebianGUIDepends = ["libice6", "libsm6", "libfontconfig1", DebianLibSslVersions];
    /// <summary>
    /// The packages that are required for CLI builds
    /// </summary>
    private static readonly IReadOnlyList<string> DebianCLIDepends = [DebianLibSslVersions];
    /// <summary>
    /// The packages that are recommended for Debian builds
    /// </summary>
    private static readonly IReadOnlyList<string> DebianRecommends = [DebianLibIcuVersions];

    /// <summary>
    /// The packages that are required for GUI builds
    /// </summary>
    private static readonly IReadOnlyList<string> FedoraGUIDepends = ["fontconfig", "libicu", "desktop-file-utils"];
    /// <summary>
    /// The packages that are required for CLI builds
    /// </summary>
    private static readonly IReadOnlyList<string> FedoraCLIDepends = ["libicu"];


    /// <summary>
    /// Structure for keeping all variables for a single release
    /// </summary>
    /// <param name="Version">The version to use</param>
    /// <param name="Channel">The release channel</param>
    /// <param name="Timestamp">The release timestamp</param>
    private record ReleaseInfo(Version Version, ReleaseChannel Channel, DateTime Timestamp)
    {
        /// <summary>
        /// Gets the string name for the release
        /// </summary>
        public string ReleaseName => $"{Version}_{Channel.ToString().ToLowerInvariant()}_{Timestamp:yyy-MM-dd}";

        /// <summary>
        /// Create a new release info
        /// </summary>
        /// <param name="type">The release type</param>
        /// <param name="date">The release date</param>
        /// <param name="version">The version</param>
        /// <param name="increment">The build version increment</param>
        /// <returns>The release info</returns>
        public static ReleaseInfo Create(ReleaseChannel type, DateTime date, Version version, int increment)
            => new ReleaseInfo(new Version(version.Major, version.Minor, version.Build, version.Revision + increment), type, date);
    }

    /// <summary>
    /// Creates the build command
    /// </summary>
    /// <returns>The command</returns>
    public static System.CommandLine.Command Create()
    {
        var releaseChannelArgument = new Argument<ReleaseChannel>(
            name: "channel",
            description: "The release channel",
            getDefaultValue: () => ReleaseChannel.Canary
        );

        var buildTargetOption = new Option<PackageTarget[]>(
            name: "--targets",
            description: "The possible build targets, multiple arguments supported. Use the format os-arch.package, example: x64-win.msi.",
            parseArgument: arg =>
            {
                if (!arg.Tokens.Any())
                    return Program.SupportedPackageTargets.ToArray();

                var requested = arg.Tokens.Select(x => PackageTarget.ParsePackageId(x.Value)).Distinct().ToArray();
                var invalid = requested.Where(x => !Program.SupportedPackageTargets.Contains(x)).ToList();
                if (invalid.Any())
                    throw new Exception($"Following targets are not supported: {string.Join(", ", invalid.Select(x => x.PackageTargetString))}");

                return requested;
            });

        var gitStashPushOption = new Option<bool>(
            name: "--git-stash-push",
            description: "Performs a git stash command before running the build, and a git commit after updating files",
            getDefaultValue: () => true
        );

        var keepBuildsOption = new Option<bool>(
            name: "--keep-builds",
            description: "Do not delete the build folders if they already exist (re-use build)",
            getDefaultValue: () => false
        );

        var compileOnlyOption = new Option<bool>(
            name: "--compile-only",
            description: "Only build the binaries, do not create packages",
            getDefaultValue: () => false
        );

        var buildTempOption = new Option<DirectoryInfo>(
            name: "--build-path",
            description: "The path to the temporary folder used for builds; will be deleted on startup",
            getDefaultValue: () => new DirectoryInfo(Path.GetFullPath("build-temp"))
        );

        var solutionFileOption = new Option<FileInfo>(
            name: "--solution-file",
            description: "Path to the Duplicati.slnx file",
            getDefaultValue: () => new FileInfo(Path.GetFullPath(Path.Combine("..", "Duplicati.slnx")))
        );

        var disableAuthenticodeOption = new Option<bool>(
            name: "--disable-authenticode",
            description: "Disables authenticode signing",
            getDefaultValue: () => false
        );

        var disableCodeSignOption = new Option<bool>(
            name: "--disable-signcode",
            description: "Disables Apple signcode signing",
            getDefaultValue: () => false
        );

        var passwordOption = SharedOptions.passwordOption;

        var signkeyPinOption = new Option<string>(
            name: "--signkey-pin",
            description: "The pin to use for the signing key",
            getDefaultValue: () => string.Empty
        );

        var disableDockerPushOption = new Option<bool>(
            name: "--disable-docker-push",
            description: "Disables pushing the docker image to the repository",
            getDefaultValue: () => false
        );

        var macOsAppNameOption = new Option<string>(
            name: "--macos-app-name",
            description: "The name of the MacOS app bundle",
            getDefaultValue: () => "Duplicati.app"
        );

        var dockerRepoOption = new Option<string>(
            name: "--docker-repo",
            description: "The docker repository to push to",
            getDefaultValue: () => "duplicati/duplicati"
        );

        var changelogFileOption = new Option<FileInfo>(
            name: "--changelog-news-file",
            description: "The path to the changelog news file. Contents from this file are prepended to the changelog.",
            getDefaultValue: () => new FileInfo(Path.GetFullPath("changelog-news.txt"))
        );

        var disableNotarizeSigningOption = new Option<bool>(
            name: "--disable-notarize-signing",
            description: "Disables notarize signing for MacOS packages",
            getDefaultValue: () => false
        );

        var disableGpgSigningOption = new Option<bool>(
            name: "--disable-gpg-signing",
            description: "Disables GPG signing of packages",
            getDefaultValue: () => false
        );

        var versionOverrideOption = new Option<string?>(
            name: "--version",
            description: "Sets a custom version to use",
            getDefaultValue: () => null
        );

        var dateOverrideOption = new Option<string?>(
            name: "--date",
            description: "Sets a custom date to use for the release, in the format yyyy-MM-dd",
            getDefaultValue: () => null
        );

        var disableS3UploadOption = new Option<bool>(
            name: "--disable-s3-upload",
            description: "Disables uploading to S3",
            getDefaultValue: () => false
        );

        var disableGithubUploadOption = new Option<bool>(
            name: "--disable-github-upload",
            description: "Disables uploading to Github",
            getDefaultValue: () => false
        );

        var disableUpdateServerReloadOption = new Option<bool>(
            name: "--disable-update-server-reload",
            description: "Disables reloading the update server",
            getDefaultValue: () => false
        );

        var disableDiscourseAnnounceOption = new Option<bool>(
            name: "--disable-discourse-announce",
            description: "Disables posting to the forum",
            getDefaultValue: () => false
        );

        var useHostedBuildsOption = new Option<bool>(
            name: "--use-hosted-builds",
            description: "Create hosted builds that require .NET installed, instead of self-contained builds with no .NET dependency",
            getDefaultValue: () => false
        );

        var resumeFromUploadOption = new Option<bool>(
            name: "--resume-from-upload",
            description: "Resumes the build process from the upload step",
            getDefaultValue: () => false
        );

        var propagateReleaseOption = new Option<bool>(
            name: "--propagate-release",
            description: "Propagate the release to the next channel",
            getDefaultValue: () => false
        );

        var disableCleanSourceOption = new Option<bool>(
            name: "--disable-clean-source",
            description: "Do not clean the source tree before the build",
            getDefaultValue: () => false
        );

        var allowAssemblyMismatchOption = new Option<bool>(
            name: "--allow-assembly-mismatch",
            description: "Allow mismatched assembly versions",
            getDefaultValue: () => false
        );

        var command = new System.CommandLine.Command("build", "Builds the packages for a release") {
            gitStashPushOption,
            releaseChannelArgument,
            buildTempOption,
            buildTargetOption,
            solutionFileOption,
            keepBuildsOption,
            compileOnlyOption,
            disableAuthenticodeOption,
            disableCodeSignOption,
            passwordOption,
            signkeyPinOption,
            macOsAppNameOption,
            disableDockerPushOption,
            dockerRepoOption,
            changelogFileOption,
            disableNotarizeSigningOption,
            disableGpgSigningOption,
            versionOverrideOption,
            dateOverrideOption,
            disableS3UploadOption,
            disableGithubUploadOption,
            disableUpdateServerReloadOption,
            disableDiscourseAnnounceOption,
            useHostedBuildsOption,
            resumeFromUploadOption,
            propagateReleaseOption,
            disableCleanSourceOption,
            allowAssemblyMismatchOption
        };

        command.Handler = CommandHandler.Create<CommandInput>(DoBuild);
        return command;
    }

    /// <summary>
    /// The input for the build command
    /// </summary>
    /// <param name="Targets">The build targets</param>
    /// <param name="BuildPath">The build path</param>
    /// <param name="SolutionFile">The solution path</param>
    /// <param name="GitStashPush">If the git stash should be performed</param>
    /// <param name="Channel">The release channel</param>
    /// <param name="Version">The version override to use</param>
    /// <param name="Date">The date override to use</param>
    /// <param name="KeepBuilds">If the builds should be kept</param>
    /// <param name="CompileOnly">If only the build should be performed</param>
    /// <param name="DisableAuthenticode">If authenticode signing should be disabled</param>
    /// <param name="DisableSignCode">If signcode should be disabled</param>
    /// <param name="Password">The password to use for the keyfile</param>
    /// <param name="SignkeyPin">The pin to use for the signing key</param>
    /// <param name="DisableDockerPush">If the docker push should be disabled</param>
    /// <param name="MacOSAppName">The name of the MacOS app bundle</param>
    /// <param name="DockerRepo">The docker repository to push to</param>
    /// <param name="ChangelogNewsFile">The path to the changelog news file</param>
    /// <param name="DisableNotarizeSigning">If notarize signing should be disabled</param>
    /// <param name="DisableGpgSigning">If GPG signing should be disabled</param>
    /// <param name="DisableS3Upload">If S3 upload should be disabled</param>
    /// <param name="DisableGithubUpload">If Github upload should be disabled</param>
    /// <param name="DisableUpdateServerReload">If the update server should not be reloaded</param>
    /// <param name="DisableDiscourseAnnounce">If forum posting should be disabled</param>
    /// <param name="UseHostedBuilds">If hosted builds should be used</param>
    /// <param name="ResumeFromUpload">If the process should resume from the upload step</param>
    /// <param name="PropagateRelease">If the release should be propagated to the next channel</param>
    /// <param name="DisableCleanSource">If the source tree should not be cleaned</param>
    /// <param name="AllowAssemblyMismatch">If the assembly mismatch should be allowed</param>
    record CommandInput(
        PackageTarget[] Targets,
        DirectoryInfo BuildPath,
        FileInfo SolutionFile,
        bool GitStashPush,
        ReleaseChannel Channel,
        string? Version,
        string? Date,
        bool KeepBuilds,
        bool CompileOnly,
        bool DisableAuthenticode,
        bool DisableSignCode,
        string Password,
        string SignkeyPin,
        bool DisableDockerPush,
        string MacOSAppName,
        string DockerRepo,
        FileInfo ChangelogNewsFile,
        bool DisableNotarizeSigning,
        bool DisableGpgSigning,
        bool DisableS3Upload,
        bool DisableGithubUpload,
        bool DisableUpdateServerReload,
        bool DisableDiscourseAnnounce,
        bool UseHostedBuilds,
        bool ResumeFromUpload,
        bool PropagateRelease,
        bool DisableCleanSource,
        bool AllowAssemblyMismatch
    );

    static async Task DoBuild(CommandInput input)
    {
        if (input.ResumeFromUpload)
            input = input with { KeepBuilds = true };

        Console.WriteLine($"Building {input.Channel} release ...");
        var configuration = Configuration.Create(input.Channel);

        var buildTargets = input.Targets;

        if (!buildTargets.Any())
            buildTargets = Program.SupportedPackageTargets.ToArray();

        if (!input.SolutionFile.Exists)
            throw new FileNotFoundException($"Solution file not found: {input.SolutionFile.FullName}");

        // This could be fixed, so we will throw an exception if the build is not possible
        if (buildTargets.Any(x => x.Package == PackageType.MSI) && !configuration.IsMSIBuildPossible() && !input.CompileOnly)
            throw new Exception("WiX toolset not configured, cannot build MSI files");

        if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !configuration.IsSynologyPkgPossible())
            throw new Exception("Synology SPK files cannot be built with the current configuration");

        // This will not work, so to make it easier for non-MacOS developers, we will remove the MacOS packages
        if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !configuration.IsMacPkgBuildPossible() && !input.CompileOnly)
        {
            Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets");
            buildTargets = buildTargets.Where(x => x.Package != PackageType.MacPkg && x.Package != PackageType.DMG).ToArray();
        }

        var baseDir = Path.GetDirectoryName(input.SolutionFile.FullName) ?? throw new Exception("Path to solution file was invalid");
        var versionFilePath = Path.Combine(baseDir, "ReleaseBuilder", "build_version.txt");
        if (!File.Exists(versionFilePath))
            throw new FileNotFoundException($"Version file not found: {versionFilePath}");

        var sourceProjects = Directory.EnumerateDirectories(Path.Combine(baseDir, "Executables"), "*", SearchOption.TopDirectoryOnly)
            .Append(Path.Combine(baseDir, "Duplicati", "ShellExtension"))
            .SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly))
            .Where(x => !ExcludedProjects.Contains(Path.GetFileName(x)))
            .ToList();

        var primaryGUI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryGUIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable");
        var primaryCLI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryCLIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find cli executable");
        var primaryAgent = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryAgentProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find agent executable");
        var windowsOnly = sourceProjects.Where(x => WindowsOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
        var guiOnlyProjects = sourceProjects.Where(x => GUIOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
        var agentOnlyProjects = sourceProjects.Where(x => AgentOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
        var ephemeralProjects = sourceProjects.Where(x => EphemeralProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);

        // Put primary at the end
        sourceProjects.Remove(primaryGUI);
        sourceProjects.Remove(primaryCLI);
        sourceProjects.Remove(primaryAgent);
        sourceProjects.Add(primaryAgent);
        sourceProjects.Add(primaryCLI);
        sourceProjects.Add(primaryGUI);

        if (!File.Exists(primaryGUI))
            throw new Exception($"Failed to locate project file: {primaryGUI}");
        if (!File.Exists(primaryCLI))
            throw new Exception($"Failed to locate project file: {primaryCLI}");

        if (!input.ChangelogNewsFile.Exists)
        {
            Console.WriteLine($"Changelog news file not found: {input.ChangelogNewsFile.FullName}");
            Console.WriteLine($"Create an empty file if you want a release without changes");
            if (OperatingSystem.IsWindows())
                Console.WriteLine($"> type nul > {input.ChangelogNewsFile.FullName}");
            else
                Console.WriteLine($"> touch {input.ChangelogNewsFile.FullName}");

            Program.ReturnCode = 1;
            return;
        }

        if (input.ChangelogNewsFile.LastAccessTimeUtc < DateTime.UtcNow.AddDays(-1))
        {
            Console.WriteLine($"Changelog news file was last modified {input.ChangelogNewsFile.LastAccessTimeUtc:yyyy-MM-dd}, indicating a stale file");
            Console.WriteLine($"Please update the file with the changelog news for the release");
            Program.ReturnCode = 1;
            return;
        }

        var changelogNews = File.ReadAllText(input.ChangelogNewsFile.FullName);
        var buildDate = DateTime.Today;
        var buildDatePath = Path.Combine(input.BuildPath.FullName, "build-date.txt");
        if (!string.IsNullOrWhiteSpace(input.Date))
        {
            if (!DateTime.TryParse(input.Date, out buildDate))
                throw new Exception($"Invalid date format: {input.Date}. Expected format is yyyy-MM-dd");
        }
        else if (input.KeepBuilds && File.Exists(buildDatePath))
        {
            // If the build path already exists, use the existing date
            buildDate = DateTime.Parse(File.ReadAllText(buildDatePath));
            Console.WriteLine($"Using existing build date: {buildDate:yyyy-MM-dd}");
        }

        var releaseInfo = string.IsNullOrWhiteSpace(input.Version)
            ? ReleaseInfo.Create(input.Channel, buildDate, Version.Parse(File.ReadAllText(versionFilePath)), 1)
            : ReleaseInfo.Create(input.Channel, buildDate, Version.Parse(input.Version), 0);
        Console.WriteLine($"Building {releaseInfo.ReleaseName} ...");

        var keyfilePassword = input.Password;
        if (string.IsNullOrWhiteSpace(keyfilePassword))
            keyfilePassword = EnvHelper.GetEnvKey("KEYFILE_PASSWORD", "");
        if (string.IsNullOrWhiteSpace(keyfilePassword))
            keyfilePassword = ConsoleHelper.ReadPassword("Enter keyfile password");

        var primarySignKey = LoadKeyFile(configuration.ConfigFiles.UpdaterKeyfile.FirstOrDefault(), keyfilePassword, false);
        var additionalKeys = configuration.ConfigFiles.UpdaterKeyfile
            .Skip(1)
            .Select(x => LoadKeyFile(x, keyfilePassword, true));

        // Configure runtime environment
        var rtcfg = new RuntimeConfig(
            configuration,
            releaseInfo,
            additionalKeys.Prepend(primarySignKey).ToList(),
            keyfilePassword,
            changelogNews,
            input);

        if (buildTargets.Any(x => x.OS == OSType.Windows))
            rtcfg.ToggleAuthenticodeSigning();
        else
            rtcfg.DisableAuthenticodeSigning();
        if (buildTargets.Any(x => x.OS == OSType.MacOS))
        {
            rtcfg.ToggleSignCodeSigning();
            rtcfg.ToggleNotarizeSigning();
        }
        else
        {
            rtcfg.DisableSignCodeSigning();
            rtcfg.DisableNotarizeSigning();
        }
        rtcfg.ToggleGpgSigning();
        rtcfg.ToggleS3Upload();
        rtcfg.ToggleGithubUpload(rtcfg.ReleaseInfo.Channel);
        rtcfg.ToogleDiscourseAnnounce(rtcfg.ReleaseInfo.Channel);
        rtcfg.ToggleUpdateServerReload();
        if (input.CompileOnly)
        {
            rtcfg.DisableDockerBuilds();
        }
        else
        {
            await rtcfg.ToggleDockerBuild();

            if (!rtcfg.UseDockerBuild)
            {
                var unsupportedBuilds = buildTargets.Where(x => DockerDependingTargets.Contains(x.Package)).ToList();
                if (unsupportedBuilds.Any())
                    throw new Exception($"The following packages cannot be built without Docker: {string.Join(", ", unsupportedBuilds.Select(x => x.PackageTargetString))}");
            }
        }

        // Prevent the system from going to sleep during the build
        using var _ = new KeepAliveAssertion();

        // Preload assemblies before we delete the bin folders
        var __ = typeof(Amazon.S3.AmazonS3Client);
        var ___ = Duplicati.Library.Utility.Utility.FormatSizeString(0);

        if (rtcfg.UseGithubUpload && !input.GitStashPush)
        {
            Console.WriteLine("Github upload requested, but pushing the tag is disabled");
            Program.ReturnCode = 1;
        }

        if (!input.KeepBuilds && Directory.Exists(input.BuildPath.FullName))
        {
            Console.WriteLine($"Deleting build folder: {input.BuildPath.FullName}");
            Directory.Delete(input.BuildPath.FullName, true);
        }

        if (!Directory.Exists(input.BuildPath.FullName))
            Directory.CreateDirectory(input.BuildPath.FullName);

        // Write the build date to the build path, to support resuming builds
        File.WriteAllText(buildDatePath, buildDate.ToString("yyyy-MM-dd"));

        // Generally, the builds should happen with a clean source tree, 
        // but this can be disabled for debugging
        if (input.GitStashPush && !input.ResumeFromUpload)
            await ProcessHelper.Execute(["git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}"], workingDirectory: baseDir);

        // Inject various files that will be embedded into the build artifacts
        var revertableFiles = await PrepareSourceDirectory(baseDir, releaseInfo, rtcfg);

        // Inject a version tag into the html files
        revertableFiles.AddRange(InjectVersionIntoFiles(baseDir, releaseInfo));

        // Record the files that are replaced by the npm package
        var foldersToRemove = new List<string>();
        revertableFiles.AddRange(await ReplaceNpmPackages(baseDir, input.BuildPath.FullName, foldersToRemove, releaseInfo, rtcfg));

        // Perform the main compilations
        var sourceBuildMap = new Dictionary<InterfaceType, IEnumerable<string>> {
            { InterfaceType.GUI, sourceProjects
                .Where(x => !AgentOnlyProjects.Contains(Path.GetFileName(x))) },
            { InterfaceType.Cli, sourceProjects
                .Where(x => !GUIOnlyProjects.Contains(Path.GetFileName(x)))
                .Where(x => !AgentOnlyProjects.Contains(Path.GetFileName(x))) },
            { InterfaceType.Agent, sourceProjects
                .Where(x => AgentProjects.Contains(Path.GetFileName(x)))
            }
        };

        await Compile.BuildProjects(
            baseDir,
            input.BuildPath.FullName,
            sourceBuildMap,
            windowsOnly,
            ephemeralProjects,
            buildTargets,
            releaseInfo,
            input.KeepBuilds,
            rtcfg,
            input.UseHostedBuilds,
            input.DisableCleanSource,
            input.AllowAssemblyMismatch);

        if (input.CompileOnly)
        {
            Console.WriteLine("Build completed, skipping package creation ...");
            return;
        }

        // Create the packages
        var builtPackages = await CreatePackage.BuildPackages(baseDir, input.BuildPath.FullName, buildTargets, input.KeepBuilds, rtcfg);
        var files = builtPackages.Select(x => x.CreatedFile).ToList();


        // Build the signed manifest to be uploaded to remote storage
        var manifestfile = Path.Combine(input.BuildPath.FullName, "packages", "autoupdate.manifest");
        if (File.Exists(manifestfile) && new FileInfo(manifestfile).LastWriteTimeUtc > files.Max(x => new FileInfo(x).LastWriteTimeUtc))
        {
            Console.WriteLine("Manifest file already exists, skipping ...");
        }
        else
        {
            Console.WriteLine("Build completed, creating signed manifest ...");
            if (File.Exists(manifestfile))
                File.Delete(manifestfile);

            UpdaterManager.CreateSignedManifest(
                rtcfg.SignKeys,
                JsonSerializer.Serialize(new
                {
                    Displayname = $"Duplicati v{releaseInfo.Version} - {releaseInfo.Channel}",
                    ChangeInfo = rtcfg.ChangelogNews
                }),
                Path.Combine(input.BuildPath.FullName, "packages"),
                version: releaseInfo.Version.ToString(),
                incompatibleUpdateUrl: ReplaceVersionPlaceholders(configuration.ExtraSettings.UpdateFromIncompatibleVersionUrl, releaseInfo),
                genericUpdatePageUrl: ReplaceVersionPlaceholders(configuration.ExtraSettings.GenericUpdatePageUrl, releaseInfo),
                releaseType: releaseInfo.Channel.ToString().ToLowerInvariant(),
                packages: builtPackages.Select(x => new PackageEntry(
                    RemoteUrls: configuration.ExtraSettings.PackageUrls
                        .Select(u =>
                            ReplaceVersionPlaceholders(u, releaseInfo)
                            .Replace("${FILENAME}", HttpUtility.UrlEncode(Path.GetFileName(x.CreatedFile)))
                        ).ToArray(),
                    PackageTypeId: x.Target.PackageTargetString,
                    Length: new FileInfo(Path.Combine(input.BuildPath.FullName, x.CreatedFile)).Length,
                    MD5: CalculateHash(Path.Combine(input.BuildPath.FullName, x.CreatedFile), "md5"),
                    SHA256: CalculateHash(Path.Combine(input.BuildPath.FullName, x.CreatedFile), "sha256")
                ))
            );
        }

        // Create the GPG signatures for the files
        if (rtcfg.UseGPGSigning)
        {
            var sigfile = Path.Combine(input.BuildPath.FullName, "packages", $"duplicati-{releaseInfo.ReleaseName}.signatures.zip");
            if (File.Exists(sigfile) && new FileInfo(sigfile).LastWriteTimeUtc > files.Max(x => new FileInfo(x).LastWriteTimeUtc))
            {
                Console.WriteLine("Signature file already exists, skipping ...");
            }
            else
            {
                Console.WriteLine("Creating GPG signatures ...");
                await GpgSign.SignReleaseFiles(files, sigfile, rtcfg);
            }

            files.Add(sigfile);
        }

        // Ensure the tag is pushed before uploading, so the uploaded files
        // are associated with the release tag
        if (input.GitStashPush && !input.ResumeFromUpload)
            await GitPush.TagAndPush(baseDir, releaseInfo);

        // Propagate the release to the next channel, if selected
        var nextChannels = new[] { ReleaseChannel.Experimental, ReleaseChannel.Beta, ReleaseChannel.Stable }
            .Where(x => x > releaseInfo.Channel)
            .Where(x => rtcfg.ReleaseInfo.Channel != ReleaseChannel.Canary)
            .Where(x => input.PropagateRelease)
            .ToArray();

        if (rtcfg.UseS3Upload || rtcfg.UseGithubUpload)
        {
            Console.WriteLine("Build completed, uploading packages ...");
            if (rtcfg.UseS3Upload)
            {
                var manifestNames = new[] { $"duplicati-{releaseInfo.ReleaseName}.manifest", "latest-v2.manifest" };

                var packageJson = Path.Combine(input.BuildPath.FullName, "packages", "latest-v2.json");
                var content = Upload.CreatePackageJson(builtPackages, rtcfg);

                File.WriteAllText(packageJson, content);

                var uploads = files.Select(x => new Upload.UploadFile(x, Path.GetFileName(x)))
                   .Concat(manifestNames.Select(x => new Upload.UploadFile(manifestfile, x)))
                   .Append(new Upload.UploadFile(packageJson, Path.GetFileName(packageJson)))
                   .Append(new Upload.UploadFile(packageJson, $"latest-v2-{releaseInfo.Version}.json"));

                await Upload.UploadToS3(uploads, rtcfg, nextChannels);
            }

            if (rtcfg.UseGithubUpload)
            {
                if (!rtcfg.UseS3Upload && input.GitStashPush && !input.ResumeFromUpload)
                {
                    Console.WriteLine("Waiting for Github to create the release tag ...");
                    await Task.Delay(TimeSpan.FromSeconds(30));
                }

                await Upload.UploadToGithub(
                    files.Select(x => new Upload.UploadFile(x, Path.GetFileName(x))),
                    rtcfg
                );
            }
        }

        if (rtcfg.UseUpdateServerReload)
        {
            Console.WriteLine("Release completed, reloading update server ...");
            await Upload.ReloadUpdateServer(rtcfg, nextChannels);
        }

        if (rtcfg.UseForumPosting)
        {
            Console.WriteLine("Release completed, posting release notes ...");
            await Upload.PostToForum(rtcfg);
        }

        if (input.GitStashPush)
        {
            // Contents are added to changelog, so we can remove the file
            input.ChangelogNewsFile.Delete();
        }

        // Clean up the source tree
        foreach (var folder in foldersToRemove)
            if (Directory.Exists(folder))
                Directory.Delete(folder, true);

        // This often fails and should be fixed
        try
        {
            await ProcessHelper.Execute(new[] {
                "git", "checkout",
            }.Concat(revertableFiles.Select(x => Path.GetRelativePath(baseDir, x)))
             , workingDirectory: baseDir);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Warning: Failed to clean source directory: {ex.Message}");
        }

        Console.WriteLine("All done!");
    }

    /// <summary>
    /// Replaces common placeholders with their respective values
    /// </summary>
    /// <param name="input">The string to update</param>
    /// <param name="releaseInfo">The release info to replace with</param>
    /// <param name="urlEncode">If the values should be URL encoded</param>
    /// <returns>The updated string</returns>
    private static string ReplaceVersionPlaceholders(string input, ReleaseInfo releaseInfo, bool urlEncode = true)
    {
        return input
            .Replace("${RELEASE_VERSION}", urlEncode ? HttpUtility.UrlEncode(releaseInfo.Version.ToString()) : releaseInfo.Version.ToString())
            .Replace("${RELEASE_TIMESTAMP}", urlEncode ? HttpUtility.UrlEncode(releaseInfo.Timestamp.ToString("yyyy-MM-dd")) : releaseInfo.Timestamp.ToString("yyyy-MM-dd"))
            .Replace("${RELEASE_CHANNEL}", urlEncode ? HttpUtility.UrlEncode(releaseInfo.Channel.ToString().ToLowerInvariant()) : releaseInfo.Channel.ToString().ToLowerInvariant())
            .Replace("${RELEASE_TYPE}", urlEncode ? HttpUtility.UrlEncode(releaseInfo.Channel.ToString().ToLowerInvariant()) : releaseInfo.Channel.ToString().ToLowerInvariant());
    }

    /// <summary>
    /// Injects the version number into some html files
    /// </summary>
    /// <param name="baseDir">The base directory</param>
    /// <param name="releaseInfo">The release info</param>
    /// <returns>The paths that were modified</returns>
    private static IEnumerable<string> InjectVersionIntoFiles(string baseDir, ReleaseInfo releaseInfo)
    {
        var targetfiles = Directory.EnumerateFiles(Path.Combine(baseDir, "Duplicati", "Server", "webroot"), "*", SearchOption.AllDirectories)
            .Where(x => x.EndsWith(".html") || x.EndsWith(".js"))
            .ToList();

        var versionre = @"(?<version>\d+\.\d+\.(\*|(\d+(\.(\*|\d+)))?))";
        var regex = new Regex(@"\?v\=" + versionre);
        foreach (var file in targetfiles)
            File.WriteAllText(
                file,
                regex.Replace(File.ReadAllText(file), $"?v={releaseInfo.Version}")
            );

        var wixFileGUI = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "Windows", "TrayIcon", "UpgradeData.wxi");
        File.WriteAllText(
            wixFileGUI,
            Regex.Replace(
                File.ReadAllText(wixFileGUI),
                @"\<\?define ProductVersion\=\""" + versionre + @"\"" \?\>",
                $"<?define ProductVersion=\"{releaseInfo.Version}\" ?>"
            )
        );

        targetfiles.Add(wixFileGUI);

        var wixFileAgent = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "Windows", "Agent", "UpgradeData.wxi");
        File.WriteAllText(
            wixFileAgent,
            Regex.Replace(
                File.ReadAllText(wixFileAgent),
                @"\<\?define ProductVersion\=\""" + versionre + @"\"" \?\>",
                $"<?define ProductVersion=\"{releaseInfo.Version}\" ?>"
            )
        );

        targetfiles.Add(wixFileAgent);

        return targetfiles;
    }

    /// <summary>
    /// Updates the source directory prior to building.
    /// This writes a stub package manifest inside the source folder,
    /// which will be embedded in the excutable to indicate which version it was built from.
    /// </summary>
    /// <param name="baseDir">The source folder base</param>
    /// <param name="releaseInfo">The release info to use</param>
    /// <param name="rtcfg">The runtime configuration</param>
    /// <returns>Modified files</returns>
    static Task<List<string>> PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, RuntimeConfig rtcfg)
    {
        var urlstring = string.Join(";", rtcfg.Configuration.ExtraSettings.UpdaterUrls.Select(x =>
            ReplaceVersionPlaceholders(x, releaseInfo)
            .Replace("${FILENAME}", HttpUtility.UrlEncode("latest-v2.manifest"))
        ));

        File.WriteAllText(Path.Combine(baseDir, "Duplicati", "License", "VersionTag.txt"), releaseInfo.ReleaseName);
        File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"), releaseInfo.Channel.ToString().ToLowerInvariant());
        File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"), urlstring);
        File.WriteAllLines(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKeys.txt"), rtcfg.SignKeys.Select(x => x.ToXmlString(false)));

        if (!string.IsNullOrWhiteSpace(rtcfg.ChangelogNews))
        {
            var current = File.ReadAllText(Path.Combine(baseDir, "changelog.txt"));
            var previousEntry = current.IndexOf(rtcfg.ChangelogNews);
            if (previousEntry >= 0 && previousEntry < 200)
            {
                Console.WriteLine("Note: release already in changelog, skipping ...");
            }
            else
            {
                File.WriteAllText(
                    Path.Combine(baseDir, "changelog.txt"),

                    string.Join(Environment.NewLine, [
                        $"{rtcfg.ReleaseInfo.Timestamp:yyy-MM-dd} - {rtcfg.ReleaseInfo.ReleaseName}",
                        "==========",
                        rtcfg.ChangelogNews.Trim(),
                        "",
                        current
                    ])
                );
            }
        }

        // Previous versions used to install the assembly redirects after the build
        // but it looks like .Net now handles this automatically
        // If not, we need to adjust the .csproj files before building
        // find "${UPDATE_SOURCE}" - type f - name Duplicati.*.exe - maxdepth 1 - exec cp Installer/ AssemblyRedirects.xml { }.config \;

        var manifestFile = Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "autoupdate.manifest");
        if (File.Exists(manifestFile))
            File.Delete(manifestFile);

        UpdaterManager.CreateSignedManifest(
            rtcfg.SignKeys,
            JsonSerializer.Serialize(new { Displayname = $"Duplicati v{releaseInfo.Version} - {releaseInfo.Channel}" }),
            Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater"),
            version: releaseInfo.Version.ToString(),
            incompatibleUpdateUrl: ReplaceVersionPlaceholders(rtcfg.Configuration.ExtraSettings.UpdateFromIncompatibleVersionUrl, releaseInfo),
            genericUpdatePageUrl: ReplaceVersionPlaceholders(rtcfg.Configuration.ExtraSettings.GenericUpdatePageUrl, releaseInfo),
            releaseType: releaseInfo.Channel.ToString().ToLowerInvariant()
        );

        return Task.FromResult(new List<string> {
            manifestFile,
            Path.Combine(baseDir, "Duplicati", "License", "VersionTag.txt"),
            Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"),
            Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"),
            Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKeys.txt"),
            Path.Combine(baseDir, "changelog.txt")
        });
    }

    /// <summary>
    /// Uses NPM to replace the node_modules folder in the webroot with a fresh install
    /// </summary>
    /// <param name="baseDir">The base directory</param>
    /// <param name="buildDir">The build directory</param>
    /// <param name="foldersToRemove">The folders to remove after the build</param>
    /// <param name="releaseInfo">The release info</param>
    /// <param name="rtcfg">The runtime configuration</param>
    /// <returns>The files that were deleted</returns>
    static async Task<List<string>> ReplaceNpmPackages(string baseDir, string buildDir, List<string> foldersToRemove, ReleaseInfo releaseInfo, RuntimeConfig rtcfg)
    {
        var deleted = new List<string>();

        // Find all webroot folders with a package.json and package-lock.json
        var webroot = Path.Combine(baseDir, "Duplicati", "Server", "webroot");
        var targets = Directory.EnumerateDirectories(webroot, "*", SearchOption.TopDirectoryOnly)
            .Where(x => File.Exists(Path.Combine(x, "package.json")) && File.Exists(Path.Combine(x, "package-lock.json")))
            .ToList();

        foreach (var target in targets)
        {
            if (string.IsNullOrWhiteSpace(rtcfg.Configuration.Commands.Npm))
                throw new Exception("NPM command not found, but required for building");

            // Remove existing node_modules folder
            if (Directory.Exists(Path.Combine(target, "node_modules")))
                Directory.Delete(Path.Combine(target, "node_modules"), true);

            // These will be deleted after the build
            deleted.AddRange(Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories));

            var tmp = Path.Combine(buildDir, "tmp-npm");
            if (Directory.Exists(tmp))
                Directory.Delete(tmp, true);

            Directory.CreateDirectory(tmp);
            EnvHelper.CopyDirectory(target, tmp, true);

            // Run npm install in the temporary folder
            await ProcessHelper.Execute([rtcfg.Configuration.Commands.Npm, "ci"], workingDirectory: tmp);
            var basefolder = Directory.EnumerateDirectories(Path.Combine(tmp, "node_modules"), "*", SearchOption.AllDirectories)
                .Where(x => File.Exists(Path.Combine(x, "index.html")))
                .OrderBy(x => x.Length)
                .FirstOrDefault();

            if (string.IsNullOrWhiteSpace(basefolder))
                throw new Exception($"Failed to locate node_modules folder in {target}");

            Directory.Delete(target, true);
            Directory.Move(basefolder, target);
            Directory.Delete(tmp, true);
            foldersToRemove.Add(target);

            var indexfile = Path.Combine(target, "index.html");
            if (File.Exists(indexfile))
            {
                var content = File.ReadAllText(indexfile);
                var prefix = target.Substring(webroot.Length).Replace(Path.DirectorySeparatorChar, '/').Trim('/');
                content = content.Replace("<base href=\"/\">", $"<base href=\"/{prefix}/\">");
                File.WriteAllText(indexfile, content);
            }
        }

        return deleted.Where(x => !Path.GetFileName(x).StartsWith("."))
            .ToList();
    }


    /// <summary>
    /// Loads a keyfile and decrypts the RSA key inside
    /// </summary>
    /// <param name="keyfile">The keyfile to decrypt</param>
    /// <param name="password">The keyfile password</param>
    /// <param name="askForNewPassword">Allow asking for a new password if the password did not match</param>
    /// <returns>The matching key</returns>
    static RSA LoadKeyFile(string? keyfile, string password, bool askForNewPassword)
    {
        if (string.IsNullOrWhiteSpace(keyfile))
            throw new Exception("Unable to load keyfile, no keyfile specified");

        if (!File.Exists(keyfile))
            throw new FileNotFoundException($"Keyfile not found: {keyfile}");

        try
        {
            using var ms = new MemoryStream();
            using var fs = File.OpenRead(keyfile);
            SharpAESCrypt.AESCrypt.Decrypt(password, fs, ms);

            var rsa = RSA.Create();
            rsa.FromXmlString(System.Text.Encoding.UTF8.GetString(ms.ToArray()));

            return rsa;
        }
        catch (SharpAESCrypt.WrongPasswordException)
        {
            if (!askForNewPassword)
                throw;
        }

        password = ConsoleHelper.ReadPassword($"Enter password for {keyfile}");
        return LoadKeyFile(keyfile, password, false);
    }

    /// <summary>
    /// Calculates the hash of a file and returns the hash as a base64 string
    /// </summary>
    /// <param name="file">The file to calculate the has for</param>
    /// <param name="algorithm">The hash algorithm</param>
    /// <returns>The base64 encoded hash</returns>
    static string CalculateHash(string file, string algorithm)
    {
        using var fs = File.OpenRead(file);
        using var hash = algorithm.ToLowerInvariant() switch
        {
            "md5" => (HashAlgorithm)MD5.Create(),
            "sha256" => SHA256.Create(),
            _ => throw new Exception($"Unknown hash algorithm: {algorithm}")
        };

        return Convert.ToBase64String(hash.ComputeHash(fs));
    }
}
