// Copyright (C) 2025, 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.Linq;
using Duplicati.Server.Serialization.Interface;
using System.Text;
using Duplicati.Library.RestAPI;
using Duplicati.Library.Encryption;
using Duplicati.Library.DynamicLoader;
using Duplicati.Library.Main;
using Duplicati.Library.AutoUpdater;
using System.Data;
using Duplicati.Library.Main.Database;
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using System.Text.RegularExpressions;
using Duplicati.WebserverCore.Abstractions;
using System.Text.Json;

#nullable enable

namespace Duplicati.Server.Database
{
    public class Connection : IDisposable
    {
        /// <summary>
        /// The placeholder for passwords in the UI
        /// </summary>
        public const string PASSWORD_PLACEHOLDER = "***************";

        private readonly IDbConnection m_connection;
        private readonly IDbCommand m_errorcmd;
        public readonly object m_lock = new object();
        public const int ANY_BACKUP_ID = -1;
        public const int SERVER_SETTINGS_ID = -2;
        private readonly Dictionary<string, Backup> m_temporaryBackups = new Dictionary<string, Backup>();
        private readonly bool m_encryptSensitiveFields;
        private readonly EncryptedFieldHelper.KeyInstance? m_key;
        private IServiceProvider? m_serviceProvider;
        private INotificationUpdateService? m_notificationUpdateService;
        private EventPollNotify? m_eventPollNotifyer;
        private readonly string m_dataFolder;

        private static readonly HashSet<string> _encryptedFields =
            BackendLoader.Backends.SelectMany(x => x.SupportedCommands ?? [])
                .Concat(EncryptionLoader.Modules.SelectMany(x => x.SupportedCommands ?? []))
                .Concat(CompressionLoader.Modules.SelectMany(x => x.SupportedCommands ?? []))
                .Concat(GenericLoader.Modules.SelectMany(x => x.SupportedCommands ?? []))
                .Concat(WebLoader.Modules.SelectMany(x => x.SupportedCommands ?? []))
                .Concat(SourceProviderLoader.Modules.SelectMany(x => x.SupportedCommands ?? []))
                .Concat(RestoreDestinationProviderLoader.Modules.SelectMany(x => x.SupportedCommands ?? []))
                .Concat(new Options(new Dictionary<string, string?>()).SupportedCommands)
                .Where(x => x.Type == Library.Interface.CommandLineArgument.ArgumentType.Password)
                .SelectMany(x => new string[] { x.Name }.Concat(x.Aliases ?? []))
                .SelectMany(x => new string[] { x, $"--{x}" })
                .Concat([
                    ServerSettings.CONST.JWT_CONFIG,
                    ServerSettings.CONST.PBKDF_CONFIG,
                    ServerSettings.CONST.REMOTE_CONTROL_CONFIG,
                    ServerSettings.CONST.SERVER_SSL_CERTIFICATE,
                    ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD,
                    ServerSettings.CONST.REMOTE_CONTROL_STORAGE_API_KEY,
                    ServerSettings.CONST.CLIENT_LICENSE_KEY
                ])
            .ToHashSet(StringComparer.OrdinalIgnoreCase);

        public static IReadOnlySet<string> PasswordFieldNames => _encryptedFields;

        private Action m_startOrStopUsageReporter;

        public Connection(IDbConnection connection, bool disableFieldEncryption, EncryptedFieldHelper.KeyInstance? key, string dataFolder, Action startOrStopUsageReporter)
        {
            m_dataFolder = dataFolder;
            m_encryptSensitiveFields = !disableFieldEncryption;
            m_key = key;
            m_connection = connection;
            m_errorcmd = m_connection.CreateCommand(@"INSERT INTO ""ErrorLog"" (""BackupID"", ""Message"", ""Exception"", ""Timestamp"") VALUES (@BackupId,@Message,@Exception,@Timestamp)");
            m_startOrStopUsageReporter = startOrStopUsageReporter;

            this.ApplicationSettings = new ServerSettings(this);
        }

        /// <summary>
        /// The service provider is used to resolve dependencies
        /// </summary>
        internal IServiceProvider? ServiceProvider => m_serviceProvider;

        /// <summary>
        /// Set the service provider to be used for resolving dependencies
        /// </summary>
        /// <param name="sp">The service provider</param>
        public void SetServiceProvider(IServiceProvider sp)
        {
            m_serviceProvider = sp;
            m_notificationUpdateService = sp?.GetRequiredService<INotificationUpdateService>();
            m_eventPollNotifyer = sp?.GetRequiredService<EventPollNotify>();
        }

        public bool IsEncryptingFields => m_encryptSensitiveFields;

        public void ReWriteAllFieldsIfEncryptionChanged()
        {
            // The token is automatically decrypted when the settings are loaded
            // In case the password has changed, this will fail and return the encrypted
            // hex-string, but will crash before reaching this point
            if (this.ApplicationSettings.EncryptedFields != m_encryptSensitiveFields)
            {
                var backups = this.Backups;
                foreach (var b in backups)
                {
                    ((Backup)b).LoadChildren(this);
                    AddOrUpdateBackup(b, false, null);
                }

                this.SetSettings(this.GetSettings(ANY_BACKUP_ID), ANY_BACKUP_ID);
                this.ApplicationSettings.EncryptedFields = m_encryptSensitiveFields;

                // Also vacuum the database to reclaim space and flush any unencrypted data
                using var cmd = this.m_connection.CreateCommand("VACUUM");
                cmd.ExecuteNonQuery();
            }
        }

        public void SetPreloadSettingsIfChanged(Dictionary<string, string> newsettings)
        {
            if (newsettings == null || newsettings.Count == 0)
                return;

            var settingsHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(newsettings.OrderBy(x => x.Key)))));
            if (settingsHash == this.ApplicationSettings.PreloadSettingsHash)
                return;

            newsettings = newsettings
                .ToDictionary(x => x.Key.StartsWith("--") ? x.Key : $"--{x.Key}", x => x.Value);

            var currentSettings = this.Settings;
            var filters = currentSettings.Where(x => x.Filter != null).ToDictionary(x => x.Name, x => x.Filter);

            var updatedSettings = currentSettings
                .Where(x => !newsettings.ContainsKey(x.Name))
                .Concat(newsettings.Where(x => x.Value != null).Select(x => new Setting
                {
                    Name = x.Key,
                    Value = x.Value,
                    Filter = filters.GetValueOrDefault(x.Key) ?? ""
                }));

            this.Settings = updatedSettings.ToArray();
            this.ApplicationSettings.PreloadSettingsHash = settingsHash;
        }

        public void LogError(string? backupid, string message, Exception ex)
        {
            lock (m_lock)
            {
                if (!long.TryParse(backupid, out long id))
                    id = -1;

                m_errorcmd.SetParameterValue("@BackupId", id)
                    .SetParameterValue("@Message", message)
                    .SetParameterValue("@Exception", ex?.ToString())
                    .SetParameterValue("@Timestamp", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow))
                    .ExecuteNonQuery();
            }
        }

        public void ExecuteWithCommand(Action<IDbCommand> f)
        {
            lock (m_lock)
                using (var cmd = m_connection.CreateCommand())
                    f(cmd);
        }

        public Serializable.ImportExportStructure PrepareBackupForExport(IBackup backup)
        {
            var scheduleId = GetScheduleIDsFromTags(new string[] { "ID=" + backup.ID });
            return new Serializable.ImportExportStructure()
            {
                CreatedByVersion = UpdaterManager.SelfVersion.Version ?? "Unknown",
                Backup = (Database.Backup)backup,
                Schedule = scheduleId != null && scheduleId.Any() ? (Schedule?)GetSchedule(scheduleId.First()) : null,
                DisplayNames = SpecialFolders.GetSourceNames(backup)
            };
        }

        public string RegisterTemporaryBackup(IBackup backup)
        {
            lock (m_lock)
            {
                if (backup == null)
                    throw new ArgumentNullException(nameof(backup));
                if (backup.ID != null)
                    throw new ArgumentException("Backup is already active, cannot make temporary");

                backup.ID = Guid.NewGuid().ToString("D");
                m_temporaryBackups.Add(backup.ID, (Backup)backup);
                return backup.ID;
            }
        }

        public void UnregisterTemporaryBackup(IBackup backup)
        {
            lock (m_lock)
                m_temporaryBackups.Remove(backup.ID);
        }

        public void UpdateTemporaryBackup(IBackup backup)
        {
            lock (m_lock)
                if (m_temporaryBackups.Remove(backup.ID))
                    m_temporaryBackups.Add(backup.ID, (Backup)backup);
        }

        public IBackup? GetTemporaryBackup(string id)
        {
            if (string.IsNullOrEmpty(id))
                return null;

            lock (m_lock)
            {
                m_temporaryBackups.TryGetValue(id, out var b);
                return b?.Clone();
            }
        }

        public ServerSettings ApplicationSettings { get; private set; }

        internal IDictionary<string, string?> GetMetadata(long id)
        {
            lock (m_lock)
                return ReadFromDb(
                    (rd) => new KeyValuePair<string, string?>(
                        ConvertToString(rd, 0) ?? "",
                        ConvertToString(rd, 1)
                    ),
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""Name"", ""Value"" FROM ""Metadata"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id)
                )
                .ToDictionary((k) => k.Key, (k) => k.Value);
        }

        internal void SetMetadata(IDictionary<string, string> values, long id, IDbTransaction? transaction)
        {
            lock (m_lock)
            {
                var tr = transaction ?? m_connection.BeginTransaction();
                OverwriteAndUpdateDb(
                    tr,
                    cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Metadata"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id),
                    values ?? new Dictionary<string, string>(),
                    cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Metadata"" (""BackupID"", ""Name"", ""Value"") VALUES (@BackupId, @Name, @Value)"),
                    (cmd, f) => cmd.SetParameterValue("@BackupId", id)
                        .SetParameterValue("@Name", f.Key)
                        .SetParameterValue("@Value", f.Value)
                );

                if (transaction == null)
                {
                    tr.Commit();
                    tr.Dispose();
                }
            }
        }

        internal IFilter[] GetFilters(long id)
        {
            lock (m_lock)
                return ReadFromDb(
                    (rd) => (IFilter)new Filter()
                    {
                        Order = ConvertToInt64(rd, 0),
                        Include = ConvertToBoolean(rd, 1),
                        Expression = ConvertToString(rd, 2) ?? ""
                    },
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""Order"", ""Include"", ""Expression"" FROM ""Filter"" WHERE ""BackupID"" = @Id ORDER BY ""Order"" ")
                        .SetParameterValue("@Id", id))
                    .ToArray();
        }

        internal void SetFilters(IEnumerable<IFilter> values, long id, IDbTransaction? transaction = null)
        {
            lock (m_lock)
            {
                var tr = transaction ?? m_connection.BeginTransaction();
                OverwriteAndUpdateDb(
                    tr,
                    cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Filter"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id),
                    values,
                    cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Filter"" (""BackupID"", ""Order"", ""Include"", ""Expression"") VALUES (@Id, @Order, @Include, @Expression)"),
                    (cmd, f) => cmd.SetParameterValue("@Id", id)
                        .SetParameterValue("@Order", f.Order)
                        .SetParameterValue("@Include", f.Include)
                        .SetParameterValue("@Expression", f.Expression)
                );

                if (transaction == null)
                {
                    tr.Commit();
                    tr.Dispose();
                }
            }
        }

        /// <summary>
        /// Checks if the given value is the password placeholder.
        /// </summary>
        /// <param name="value">The value to check.</param>
        /// <returns><c>true</c> if the value is the password placeholder; otherwise <c>false</c>.</returns>
        public static bool IsPasswordPlaceholder(string? value)
            => string.IsNullOrWhiteSpace(value)
                ? false
                : value.Length > 3 && value.All(c => c == '*');

        /// <summary>
        /// Regular expression to match a URL password mask (3 or more asterisks or %2A)
        /// </summary>
        private static Regex UrlPasswordMaskRegex = new Regex(@"(\*|%2A){3,}", RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Checks if the given url contains a password placeholder.
        /// </summary>
        /// <param name="url">The url to check.</param>
        /// <returns><c>true</c> if the value contains a password placeholder; otherwise <c>false</c>.</returns>
        public static bool UrlContainsPasswordPlaceholder(string? url)
            => !string.IsNullOrWhiteSpace(url) && UrlPasswordMaskRegex.IsMatch(url);

        public ISetting[] GetSettings(long id)
        {
            lock (m_lock)
                return ReadFromDb(
                    (rd) => (ISetting)new Setting()
                    {
                        Filter = ConvertToString(rd, 0) ?? "",
                        Name = ConvertToString(rd, 1) ?? "",
                        Value = DecryptSensitiveFields(ConvertToString(rd, 2) ?? "", m_key)
                        //TODO: Attach the argument information
                    },
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""Filter"", ""Name"", ""Value"" FROM ""Option"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id))
                    .ToArray();
        }

        internal void SetSettings(IEnumerable<ISetting> values, long id, IDbTransaction? transaction = null)
        {
            lock (m_lock)
            {
                var tr = transaction ?? m_connection.BeginTransaction();
                if (m_encryptSensitiveFields)
                    values = values.Select(x => new Setting
                    {
                        Filter = x.Filter,
                        Name = x.Name,
                        Value = EncryptSensitiveFields(x.Name, x.Value, m_key)
                    }).ToList();

                OverwriteAndUpdateDb(
                    tr,
                    cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Option"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id),
                    values,
                    cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Option"" (""BackupID"", ""Filter"", ""Name"", ""Value"") VALUES (@BackupId, @Filter, @Name, @Value)"),
                    (cmd, f) =>
                    {
                        if (IsPasswordPlaceholder(f.Value))
                            throw new Exception("Attempted to save a property with the placeholder password");

                        cmd.SetParameterValue("@BackupId", id)
                            .SetParameterValue("@Filter", f.Filter ?? "")
                            .SetParameterValue("@Name", f.Name ?? "")
                            .SetParameterValue("@Value", f.Value ?? "");
                    }
                );

                if (transaction == null)
                {
                    tr.Commit();
                    tr.Dispose();
                }
            }

            if (id == SERVER_SETTINGS_ID || id == ANY_BACKUP_ID)
                SignalSettingsChanged();
        }

        private void SignalSettingsChanged()
        {
            var provider = ServiceProvider;
            if (provider != null)
            {
                provider?.GetRequiredService<INotificationUpdateService>()?.IncrementLastDataUpdateId();
                provider?.GetRequiredService<EventPollNotify>()?.SignalNewEvent();
                provider?.GetRequiredService<EventPollNotify>()?.SignalServerSettingsUpdated();
                // If throttle options were changed, update now
                provider?.GetRequiredService<IQueueRunnerService>()?.GetCurrentTask()?.UpdateThrottleSpeeds(ApplicationSettings.UploadSpeedLimit, ApplicationSettings.DownloadSpeedLimit);
                provider?.GetRequiredService<LiveControls>()?.UpdatePowerModeProvider();
            }

            // In case the usage reporter is enabled or disabled, refresh now
            m_startOrStopUsageReporter?.Invoke();
        }


        internal string?[] GetSources(long id)
        {
            lock (m_lock)
                return ReadFromDb(
                    (rd) =>
                    {
                        var s = ConvertToString(rd, 0);
                        return EncryptedFieldHelper.IsEncryptedString(s)
                            ? EncryptedFieldHelper.Decrypt(s, m_key)
                            : s;
                    },
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""Path"" FROM ""Source"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id))
                    .ToArray();
        }

        internal void SetSources(IEnumerable<string> values, long id, IDbTransaction transaction)
        {
            lock (m_lock)
            {
                var tr = transaction ?? m_connection.BeginTransaction();
                OverwriteAndUpdateDb(
                    tr,
                    cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""Source"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", id),
                    values,
                    cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Source"" (""BackupID"", ""Path"") VALUES (@BackupId, @Path)"),
                    (cmd, f) =>
                    {
                        if (SourceMasking.IsSpecialSource(f) && UrlContainsPasswordPlaceholder(SourceMasking.ExtractUrl(f)))
                            throw new Exception("Attempted to save a source with a placeholder password");

                        cmd.SetParameterValue("@BackupId", id)
                        .SetParameterValue("@Path",
                            m_encryptSensitiveFields && SourceMasking.IsSpecialSource(f)
                                ? EncryptedFieldHelper.Encrypt(f, m_key)
                                : f);
                    }
                );

                if (transaction == null)
                {
                    tr.Commit();
                    tr.Dispose();
                }
            }
        }

        internal long[] GetBackupIDsForTags(string[] tags)
        {
            if (tags == null || tags.Length == 0)
                return new long[0];

            if (tags.Length == 1 && tags[0].StartsWith("ID=", StringComparison.Ordinal))
                return new long[] { long.Parse(tags[0].Substring("ID=".Length)) };

            lock (m_lock)
                using (var cmd = m_connection.CreateCommand())
                {
                    var sb = new StringBuilder();

                    foreach (var t in tags)
                    {
                        if (sb.Length != 0)
                            sb.Append(" OR ");
                        sb.Append(@" (',' || ""Tags"" || ',' LIKE '%,' || ? || ',%') ");

                        var p = cmd.CreateParameter();
                        p.Value = t;
                        cmd.Parameters.Add(p);
                    }

                    cmd.SetCommandAndParameters(@"SELECT ""ID"" FROM ""Backup"" WHERE " + sb);

                    return Read(cmd, (rd) => ConvertToInt64(rd, 0)).ToArray();
                }
        }

        public IBackup? GetBackup(string id)
        {
            if (string.IsNullOrWhiteSpace(id))
                throw new ArgumentNullException(nameof(id));

            return long.TryParse(id, out long lid) ? GetBackup(lid) : GetTemporaryBackup(id);
        }

        internal IBackup? GetBackup(long id)
        {
            lock (m_lock)
            {
                var bk = ReadFromDb(
                    (rd) => new Backup
                    {
                        ID = ConvertToInt64(rd, 0).ToString(),
                        Name = ConvertToString(rd, 1),
                        Description = ConvertToString(rd, 2),
                        Tags = (ConvertToString(rd, 3) ?? "").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
                        TargetURL = EncryptedFieldHelper.Decrypt(ConvertToString(rd, 4), m_key),
                        DBPath = ConvertToString(rd, 5),
                    },
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Name"", ""Description"", ""Tags"", ""TargetURL"", ""DBPath"" FROM ""Backup"" WHERE ID = @Id")
                        .SetParameterValue("@Id", id))
                    .FirstOrDefault();

                if (bk != null)
                    bk.LoadChildren(this);

                return bk;
            }
        }

        public ISchedule? GetSchedule(long id)
        {
            lock (m_lock)
            {
                var bk = ReadFromDb(
                    (rd) => new Schedule
                    {
                        ID = ConvertToInt64(rd, 0),
                        Tags = (ConvertToString(rd, 1) ?? "").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
                        Time = ConvertToDateTime(rd, 2),
                        Repeat = ConvertToString(rd, 3),
                        LastRun = ConvertToDateTime(rd, 4),
                        Rule = ConvertToString(rd, 5),
                    },
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Tags"", ""Time"", ""Repeat"", ""LastRun"", ""Rule"" FROM ""Schedule"" WHERE ID = @Id")
                        .SetParameterValue("@Id", id))
                    .FirstOrDefault();

                return bk;
            }
        }

        public bool IsUnencryptedOrPassphraseStored(long id)
        {
            lock (m_lock)
            {
                var usesEncryption = ReadFromDb(
                    (rd) => ConvertToBoolean(rd, 0),
                    cmd => cmd.SetCommandAndParameters(@"SELECT VALUE != '' FROM ""Option"" WHERE BackupID = @Id AND NAME='encryption-module'")
                        .SetParameterValue("@Id", id))
                    .FirstOrDefault();

                if (!usesEncryption)
                {
                    return true;
                }

                return ReadFromDb(
                    (rd) => ConvertToBoolean(rd, 0),
                    cmd => cmd.SetCommandAndParameters(@"SELECT VALUE != '' FROM ""Option"" WHERE BackupID = @Id AND NAME='passphrase'")
                        .SetParameterValue("@Id", id))
                .FirstOrDefault();
            }
        }

        public long[] GetScheduleIDsFromTags(string[] tags)
        {
            if (tags == null || tags.Length == 0)
                return new long[0];

            lock (m_lock)
                using (var cmd = m_connection.CreateCommand())
                {
                    var sb = new StringBuilder();
                    var parameters = new Dictionary<string, object?>();
                    foreach ((var t, var i) in tags.Select((t, i) => (t, i)))
                    {
                        if (sb.Length != 0)
                            sb.Append(" OR ");
                        sb.Append(@$" (',' || ""Tags"" || ',' LIKE '%,' || @p{i} || ',%') ");
                        parameters.Add($"@p{i}", t);
                    }

                    cmd.SetCommandAndParameters(@"SELECT ""ID"" FROM ""Schedule"" WHERE " + sb);
                    cmd.SetParameterValues(parameters);

                    return Read(cmd, (rd) => ConvertToInt64(rd, 0)).ToArray();
                }
        }

        public void AddOrUpdateBackupAndSchedule(IBackup item, ISchedule? schedule)
        {
            AddOrUpdateBackup(item, true, schedule);
        }

        public string? ValidateBackup(IBackup item, ISchedule? schedule)
        {
            if (string.IsNullOrWhiteSpace(item.Name))
                return "Missing a name";

            if (string.IsNullOrWhiteSpace(item.TargetURL))
                return "Missing a target";

            if (UrlContainsPasswordPlaceholder(item.TargetURL))
                return "TargetURL is password placeholder value";
            if (item.Settings != null && item.Settings.Any(x => IsPasswordPlaceholder(x.Value)))
                return "Settings is password placeholder value";

            if (item.Sources == null || item.Sources.Any(x => string.IsNullOrWhiteSpace(x)) || item.Sources.Length == 0)
                return "Invalid source list";

            if (item.Sources.Any(x => SourceMasking.IsSpecialSource(x) && UrlContainsPasswordPlaceholder(SourceMasking.ExtractUrl(x))))
                return "Source is password placeholder value";

            var disabled_encryption = false;
            var passphrase = string.Empty;
            var gpgAsymmetricEncryption = false;
            if (item.Settings != null)
            {
                foreach (var s in item.Settings)

                    if (string.Equals(s.Name, "--no-encryption", StringComparison.OrdinalIgnoreCase))
                        disabled_encryption = string.IsNullOrWhiteSpace(s.Value) || Library.Utility.Utility.ParseBool(s.Value, false);
                    else if (string.Equals(s.Name, "passphrase", StringComparison.OrdinalIgnoreCase))
                        passphrase = s.Value;
                    else if (string.Equals(s.Name, "keep-versions", StringComparison.OrdinalIgnoreCase))
                    {
                        int i;
                        if (!int.TryParse(s.Value, out i) || i <= 0)
                            return "Retention value must be a positive integer";
                    }
                    else if (string.Equals(s.Name, "keep-time", StringComparison.OrdinalIgnoreCase))
                    {
                        try
                        {
                            var ts = Library.Utility.Timeparser.ParseTimeSpan(s.Value);
                            if (ts <= TimeSpan.FromMinutes(5))
                                return "Retention value must be more than 5 minutes";
                        }
                        catch
                        {
                            return "Retention value must be a valid timespan";
                        }
                    }
                    else if (string.Equals(s.Name, "dblock-size", StringComparison.OrdinalIgnoreCase))
                    {
                        try
                        {
                            var ds = Library.Utility.Sizeparser.ParseSize(s.Value);
                            if (ds < 1024 * 1024)
                                return "DBlock size must be at least 1MB";
                        }
                        catch
                        {
                            return "DBlock value must be a valid size string";
                        }
                    }
                    else if (string.Equals(s.Name, "--blocksize", StringComparison.OrdinalIgnoreCase))
                    {
                        try
                        {
                            var ds = Library.Utility.Sizeparser.ParseSize(s.Value);
                            if (ds < 1024 || ds > int.MaxValue)
                                return "The blocksize must be at least 1KB";
                        }
                        catch
                        {
                            return "The blocksize value must be a valid size string";
                        }
                    }
                    else if (string.Equals(s.Name, "--prefix", StringComparison.OrdinalIgnoreCase))
                    {
                        if (!string.IsNullOrWhiteSpace(s.Value) && s.Value.Contains("-"))
                            return "The prefix cannot contain hyphens (-)";
                    }
                    else if (string.Equals(s.Name, "--gpg-encryption-command", StringComparison.OrdinalIgnoreCase))
                    {
                        gpgAsymmetricEncryption = string.Equals(s.Value, "--encrypt", StringComparison.OrdinalIgnoreCase);
                    }
            }

            if (!disabled_encryption && !gpgAsymmetricEncryption && string.IsNullOrWhiteSpace(passphrase))
                return "Missing passphrase";

            if (schedule != null)
            {
                try
                {
                    var ts = Library.Utility.Timeparser.ParseTimeSpan(schedule.Repeat);
                    if (ts <= TimeSpan.FromMinutes(5))
                        return "Schedule repetition time must be more than 5 minutes";
                }
                catch
                {
                    return "Schedule repetition value must be a valid timespan";
                }

            }

            return null;
        }

        public void UpdateBackupDBPath(IBackup item, string path)
        {
            lock (m_lock)
            {
                using (var tr = m_connection.BeginTransaction())
                {
                    using (var cmd = m_connection.CreateCommand(tr, @"UPDATE ""Backup"" SET ""DBPath""= @Dbpath WHERE ""ID""= @Id"))
                    {
                        cmd.SetParameterValue("@Dbpath", path)
                            .SetParameterValue("@Id", item.ID)
                            .ExecuteNonQuery();
                        tr.Commit();
                    }
                }
            }

            m_notificationUpdateService?.IncrementLastDataUpdateId();
            m_eventPollNotifyer?.SignalNewEvent();
        }

        private void AddOrUpdateBackup(IBackup item, bool updateSchedule, ISchedule? schedule)
        {
            lock (m_lock)
            {
                bool update = item.ID != null;
                if (!update && item.DBPath == null)
                {
                    var folder = m_dataFolder;
                    if (!System.IO.Directory.Exists(folder))
                        System.IO.Directory.CreateDirectory(folder);

                    for (var i = 0; i < 100; i++)
                    {
                        var guess = System.IO.Path.Combine(folder, System.IO.Path.ChangeExtension(CLIDatabaseLocator.GenerateRandomName(), ".sqlite"));
                        if (!System.IO.File.Exists(guess))
                        {
                            ((Backup)item).DBPath = guess;
                            break;
                        }
                    }

                    if (item.DBPath == null)
                        throw new Exception("Unable to generate a unique database file name");
                }

                using (var tr = m_connection.BeginTransaction())
                {
                    OverwriteAndUpdateDb(
                        tr,
                        null,
                        [item],
                        cmd =>
                        {
                            if (update)
                                cmd.SetCommandAndParameters(@"UPDATE ""Backup"" SET ""Name""=@Name, ""Description""=@Description, ""Tags""=@Tags, ""TargetURL""=@TargetUrl WHERE ""ID""=@Id");
                            else
                                cmd.SetCommandAndParameters(@"INSERT INTO ""Backup"" (""Name"", ""ExternalID"", ""Description"", ""Tags"", ""TargetURL"", ""DBPath"") VALUES (@Name,@ExternalID,@Description,@Tags,@TargetUrl,@DbPath)");
                        },
                        (cmd, n) =>
                        {
                            if (UrlContainsPasswordPlaceholder(n.TargetURL))
                                throw new Exception("Attempted to save a backup with the password placeholder");
                            if (update && long.Parse(n.ID) <= 0)
                                throw new Exception("Invalid update, cannot update application settings through update method");

                            cmd.SetParameterValue("@Name", n.Name)
                                .SetParameterValue("@Description", n.Description ?? "")
                                .SetParameterValue("@Tags", string.Join(",", n.Tags ?? new string[0]))
                                .SetParameterValue("@TargetUrl", m_encryptSensitiveFields ? EncryptedFieldHelper.Encrypt(n.TargetURL, m_key) : n.TargetURL);

                            if (update)
                                cmd.SetParameterValue("@Id", item.ID);
                            else
                                cmd.SetParameterValue("@DbPath", n.DBPath)
                                    .SetParameterValue("@ExternalID", n.ExternalID ?? "");

                        });

                    if (!update)
                        using (var cmd = m_connection.CreateCommand())
                        {
                            cmd.Transaction = tr;
                            item.ID = cmd.ExecuteScalarInt64(@"SELECT last_insert_rowid();").ToString();
                        }

                    var id = long.Parse(item.ID ?? "-1");
                    if (id <= 0)
                        throw new Exception("Invalid addition, cannot update application settings through update method");

                    SetSources(item.Sources, id, tr);
                    SetSettings(item.Settings, id, tr);
                    SetFilters(item.Filters, id, tr);
                    // Don't update the metadata if no new content is given
                    if (item.Metadata != null)
                        SetMetadata(item.Metadata, id, tr);

                    // Save additional target URLs if present
                    if (item.AdditionalTargetURLs != null)
                        SetBackupTargetUrls(id, item.AdditionalTargetURLs.Cast<TargetUrlEntry>(), tr);

                    if (updateSchedule)
                    {
                        var tags = new string[] { "ID=" + item.ID };
                        var existing = GetScheduleIDsFromTags(tags);
                        if (schedule == null && existing.Any())
                            DeleteFromDb("Schedule", existing.First(), tr);
                        else if (schedule != null)
                        {
                            if (existing.Any())
                            {
                                var cur = GetSchedule(existing.First());
                                if (cur != null)
                                {
                                    cur.AllowedDays = schedule.AllowedDays;
                                    cur.Repeat = schedule.Repeat;
                                    cur.Tags = schedule.Tags;
                                    cur.Time = schedule.Time;

                                    schedule = cur;
                                }
                                else
                                {
                                    schedule.ID = -1;
                                }
                            }
                            else
                            {
                                schedule.ID = -1;
                            }

                            schedule.Tags = tags;
                            AddOrUpdateSchedule(schedule, tr);
                        }
                    }

                    tr.Commit();
                    m_notificationUpdateService?.IncrementLastDataUpdateId();
                    m_eventPollNotifyer?.SignalNewEvent();
                    m_eventPollNotifyer?.SignalBackupListUpdate();
                }
            }
        }

        internal void AddOrUpdateSchedule(ISchedule item)
        {
            lock (m_lock)
                using (var tr = m_connection.BeginTransaction())
                {
                    AddOrUpdateSchedule(item, tr);
                    tr.Commit();
                    m_notificationUpdateService?.IncrementLastDataUpdateId();
                    m_eventPollNotifyer?.SignalNewEvent();
                    m_eventPollNotifyer?.SignalBackupListUpdate();
                }
        }

        private void AddOrUpdateSchedule(ISchedule item, IDbTransaction tr)
        {
            lock (m_lock)
            {
                bool update = item.ID >= 0;
                OverwriteAndUpdateDb(
                    tr,
                    null,
                    [item],
                    cmd =>
                    {
                        if (update)
                            cmd.SetCommandAndParameters(@"UPDATE ""Schedule"" SET ""Tags""=@Tags, ""Time""=@Time, ""Repeat""=@Repeat, ""LastRun""=@LastRun, ""Rule""=@Rule WHERE ""ID""=@Id");
                        else
                            cmd.SetCommandAndParameters(@"INSERT INTO ""Schedule"" (""Tags"", ""Time"", ""Repeat"", ""LastRun"", ""Rule"") VALUES (@Tags,@Time,@Repeat,@LastRun,@Rule)");
                    },
                    (cmd, n) =>
                    {
                        cmd.SetParameterValue("@Tags", string.Join(",", n.Tags ?? new string[0]))
                            .SetParameterValue("@Time", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(n.Time))
                            .SetParameterValue("@Repeat", n.Repeat)
                            .SetParameterValue("@LastRun", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(n.LastRun))
                            .SetParameterValue("@Rule", n.Rule ?? "");

                        if (update)
                            cmd.SetParameterValue("@Id", item.ID);
                    });

                if (!update)
                    using (var cmd = m_connection.CreateCommand(tr))
                        item.ID = cmd.ExecuteScalarInt64(@"SELECT last_insert_rowid();");
            }
        }

        public void DeleteBackup(long ID)
        {
            if (ID < 0)
                return;

            lock (m_lock)
            {
                using (var tr = m_connection.BeginTransaction())
                {
                    var existing = GetScheduleIDsFromTags(new string[] { "ID=" + ID.ToString() });
                    if (existing.Any())
                        DeleteFromDb("Schedule", existing.First(), tr);

                    DeleteFromDb("ErrorLog", ID, "BackupID", tr);
                    DeleteFromDb("Filter", ID, "BackupID", tr);
                    DeleteFromDb("Log", ID, "BackupID", tr);
                    DeleteFromDb("Metadata", ID, "BackupID", tr);
                    DeleteFromDb("Option", ID, "BackupID", tr);
                    DeleteFromDb("Source", ID, "BackupID", tr);

                    DeleteFromDb("Backup", ID, tr);

                    tr.Commit();
                }
            }

            m_notificationUpdateService?.IncrementLastDataUpdateId();
            m_eventPollNotifyer?.SignalNewEvent();
            m_eventPollNotifyer?.SignalBackupListUpdate();
        }

        public void DeleteBackup(IBackup backup)
        {
            if (backup.IsTemporary)
                UnregisterTemporaryBackup(backup);
            else
                DeleteBackup(long.Parse(backup.ID));
        }

        public void DeleteSchedule(long ID)
        {
            if (ID < 0)
                return;

            lock (m_lock)
                DeleteFromDb("Schedule", ID);

            m_notificationUpdateService?.IncrementLastDataUpdateId();
            m_eventPollNotifyer?.SignalNewEvent();
        }

        public void DeleteSchedule(ISchedule schedule)
        {
            DeleteSchedule(schedule.ID);
        }

        public IBackup[] Backups
        {
            get
            {
                lock (m_lock)
                {
                    var lst = ReadFromDb(
                        (rd) => (IBackup)new Backup()
                        {
                            ID = ConvertToInt64(rd, 0).ToString(),
                            Name = ConvertToString(rd, 1),
                            ExternalID = ConvertToString(rd, 2),
                            Description = ConvertToString(rd, 3),
                            Tags = (ConvertToString(rd, 4) ?? "").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
                            TargetURL = EncryptedFieldHelper.Decrypt(ConvertToString(rd, 5), m_key),
                            DBPath = ConvertToString(rd, 6),
                        },
                        cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Name"", ""ExternalID"", ""Description"", ""Tags"", ""TargetURL"", ""DBPath"" FROM ""Backup"" "))
                        .ToArray();

                    foreach (var n in lst)
                        n.Metadata = GetMetadata(long.Parse(n.ID));


                    return lst;
                }
            }
        }

        public ISchedule[] Schedules
        {
            get
            {
                lock (m_lock)
                    return ReadFromDb(
                        (rd) => (ISchedule)new Schedule()
                        {
                            ID = ConvertToInt64(rd, 0),
                            Tags = (ConvertToString(rd, 1) ?? "").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
                            Time = ConvertToDateTime(rd, 2),
                            Repeat = ConvertToString(rd, 3),
                            LastRun = ConvertToDateTime(rd, 4),
                            Rule = ConvertToString(rd, 5),
                        },
                        cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Tags"", ""Time"", ""Repeat"", ""LastRun"", ""Rule"" FROM ""Schedule"" "))
                        .ToArray();
            }
        }


        public IFilter[] Filters
        {
            get { return GetFilters(ANY_BACKUP_ID); }
            set { SetFilters(value, ANY_BACKUP_ID); }
        }

        public ISetting[] Settings
        {
            get { return GetSettings(ANY_BACKUP_ID); }
            set { SetSettings(value, ANY_BACKUP_ID); }
        }

        public INotification[] GetNotifications()
        {
            lock (m_lock)
                return ReadFromDb<Notification>(null).Cast<INotification>().ToArray();
        }

        public bool DismissNotification(long id)
        {
            lock (m_lock)
            {
                var notifications = GetNotifications();
                var cur = notifications.FirstOrDefault(x => x.ID == id);
                if (cur == null)
                    return false;

                DeleteFromDb(typeof(Notification).Name, id);
                this.ApplicationSettings.UnackedError = notifications.Any(x => x.ID != id && x.Type == Duplicati.Server.Serialization.NotificationType.Error);
                this.ApplicationSettings.UnackedWarning = notifications.Any(x => x.ID != id && x.Type == Duplicati.Server.Serialization.NotificationType.Warning);
            }

            m_notificationUpdateService?.IncrementLastNotificationUpdateId();
            m_eventPollNotifyer?.SignalNewEvent();
            m_eventPollNotifyer?.SignalNotificationUpdate();

            return true;
        }

        public void RegisterNotification(
            Serialization.NotificationType type,
            string title,
            string message,
            Exception? ex,
            string? backupid,
            string action,
            string? logid,
            string? messageid,
            string? logtag,
            Func<INotification, INotification[], INotification> conflicthandler)
        {
            lock (m_lock)
            {
                var notification = new Notification()
                {
                    ID = -1,
                    Type = type,
                    Title = title,
                    Message = message,
                    Exception = ex == null ? "" : ex.ToString(),
                    BackupID = backupid,
                    Action = action ?? "",
                    Timestamp = DateTime.UtcNow,
                    LogEntryID = logid,
                    MessageID = messageid,
                    MessageLogTag = logtag
                };

                var conflictResult = conflicthandler(notification, GetNotifications());
                if (conflictResult == null)
                    return;

                if (conflictResult != notification)
                    DeleteFromDb(typeof(Notification).Name, conflictResult.ID);

                OverwriteAndUpdateDb(null, null, [notification], false);

                if (type == Serialization.NotificationType.Error)
                    ApplicationSettings.UnackedError = true;
                else if (type == Serialization.NotificationType.Warning)
                    ApplicationSettings.UnackedWarning = true;
            }

            m_notificationUpdateService?.IncrementLastNotificationUpdateId();
            m_eventPollNotifyer?.SignalNewEvent();
            m_eventPollNotifyer?.SignalNotificationUpdate();
        }

        //Workaround to clean up the database after invalid settings update
        public void FixInvalidBackupId()
        {
            using (var tr = m_connection.BeginTransaction())
            using (var cmd = m_connection.CreateCommand(tr))
            {
                cmd.SetCommandAndParameters(@"DELETE FROM ""Option"" WHERE ""BackupID"" = @BackupId")
                    .SetParameterValue("@BackupId", -1)
                    .ExecuteNonQuery();
                cmd.SetCommandAndParameters(@"DELETE FROM ""Metadata"" WHERE ""BackupID"" = @BackupId")
                    .SetParameterValue("@BackupId", -1)
                    .ExecuteNonQuery();
                cmd.SetCommandAndParameters(@"DELETE FROM ""Filter"" WHERE ""BackupID"" = @BackupId")
                    .SetParameterValue("@BackupId", -1)
                    .ExecuteNonQuery();
                cmd.SetCommandAndParameters(@"DELETE FROM ""Source"" WHERE ""BackupID"" = @BackupId")
                    .SetParameterValue("@BackupId", -1)
                    .ExecuteNonQuery();

                cmd.SetCommandAndParameters(@"DELETE FROM ""Schedule"" WHERE ""Tags"" = @Tag")
                    .SetParameterValue("@Tag", "ID=-1")
                    .ExecuteNonQuery();
                tr.Commit();
            }

            ApplicationSettings.FixedInvalidBackupId = true;
        }

        public string[] GetUISettingsSchemes()
        {
            lock (m_lock)
                return ReadFromDb(
                    (rd) => ConvertToString(rd, 0) ?? "",
                    cmd => cmd.SetCommandAndParameters(@"SELECT DISTINCT ""Scheme"" FROM ""UIStorage"""))
                .ToArray();
        }

        public IDictionary<string, string> GetUISettings(string scheme)
        {
            lock (m_lock)
                return ReadFromDb(
                    (rd) => new KeyValuePair<string, string>(
                        ConvertToString(rd, 0) ?? "",
                        ConvertToString(rd, 1) ?? ""
                    ),
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""Key"", ""Value"" FROM ""UIStorage"" WHERE ""Scheme"" = @Scheme")
                        .SetParameterValue("@Scheme", scheme))
                    .GroupBy(x => x.Key)
                    .ToDictionary(x => x.Key, x => x.Last().Value);
        }

        public void SetUISettings(string scheme, IDictionary<string, string?> values, IDbTransaction? transaction = null)
        {
            lock (m_lock)
                using (var tr = transaction == null ? m_connection.BeginTransaction() : null)
                {
                    OverwriteAndUpdateDb(
                        tr,
                        cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""UIStorage"" WHERE ""Scheme"" = @Scheme")
                            .SetParameterValue("@Scheme", scheme),
                        values,
                        cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""UIStorage"" (""Scheme"", ""Key"", ""Value"") VALUES (@Scheme, @Key, @Value)"),
                        (cmd, f) =>
                        {
                            cmd.SetParameterValue("@Scheme", scheme)
                                .SetParameterValue("@Key", f.Key ?? "")
                                .SetParameterValue("@Value", f.Value ?? "");
                        }
                    );

                    if (tr != null)
                        tr.Commit();
                }
        }

        public void UpdateUISettings(string scheme, IDictionary<string, string?> values, IDbTransaction? transaction = null)
        {
            lock (m_lock)
                using (var tr = transaction == null ? m_connection.BeginTransaction() : null)
                {
                    OverwriteAndUpdateDb(
                        tr,
                        cmd => cmd.SetCommandAndParameters(@"DELETE FROM ""UIStorage"" WHERE ""Scheme"" = @Scheme AND ""Key"" IN (@Keys)")
                            .SetParameterValue("@Scheme", scheme)
                            .ExpandInClauseParameter("@Keys", values.Keys),
                        values.Where(x => x.Value != null),
                        cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""UIStorage"" (""Scheme"", ""Key"", ""Value"") VALUES (@Scheme, @Key, @Value)"),
                        (cmd, f) =>
                        {
                            cmd.SetParameterValue("@Scheme", scheme)
                                .SetParameterValue("@Key", f.Key ?? "")
                                .SetParameterValue("@Value", f.Value ?? "");
                        }
                    );

                    if (tr != null)
                        tr.Commit();
                }
        }

        public TempFile[] GetTempFiles()
        {
            lock (m_lock)
                return ReadFromDb<TempFile>(null).ToArray();
        }

        public void DeleteTempFile(long id)
        {
            lock (m_lock)
                DeleteFromDb(typeof(TempFile).Name, id);
        }

        public long RegisterTempFile(string origin, string path, DateTime expires)
        {
            var tempfile = new TempFile()
            {
                Timestamp = DateTime.Now,
                Origin = origin,
                Path = path,
                Expires = expires
            };

            OverwriteAndUpdateDb(null, null, [tempfile], false);

            return tempfile.ID;
        }

        public void PurgeLogData(DateTime purgeDate)
        {
            var t = Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(purgeDate);

            using (var tr = m_connection.BeginTransaction())
            using (var cmd = m_connection.CreateCommand(tr))
            {
                cmd.SetCommandAndParameters(@"DELETE FROM ""ErrorLog"" WHERE ""Timestamp"" < @Time")
                    .SetParameterValue("@Time", t)
                    .ExecuteNonQuery();

                tr.Commit();
            }
        }

        private static DateTime ConvertToDateTime(IDataReader rd, int index)
        {
            var unixTime = ConvertToInt64(rd, index);
            return unixTime == 0 ? new DateTime(0) : Library.Utility.Utility.EPOCH.AddSeconds(unixTime);
        }

        private static bool ConvertToBoolean(IDataReader rd, int index)
        {
            return ConvertToInt64(rd, index) == 1;
        }

        private static string? ConvertToString(IDataReader rd, int index)
        {
            var r = rd.GetValue(index);
            return r == null || r == DBNull.Value ? null : r.ToString();
        }

        private static long ConvertToInt64(IDataReader rd, int index)
        {
            try
            {
                if (!rd.IsDBNull(index))
                    return rd.GetInt64(index);
            }
            catch
            {
            }

            return -1;
        }

        private static long ExecuteScalarInt64(IDbCommand cmd, long defaultValue = -1)
        {
            using (var rd = cmd.ExecuteReader())
                return rd.Read() ? ConvertToInt64(rd, 0) : defaultValue;
        }

        private static string? ExecuteScalarString(IDbCommand cmd)
        {
            using (var rd = cmd.ExecuteReader())
                return rd.Read() ? ConvertToString(rd, 0) : null;

        }

        private object? ConvertToEnum(Type enumType, IDataReader rd, int index, object? @default)
        {
            try
            {
                return Enum.Parse(enumType, ConvertToString(rd, index) ?? string.Empty, true);
            }
            catch
            {
            }

            return @default;
        }

        // Overloaded function for legacy functionality
        private bool DeleteFromDb(string tablename, long id, IDbTransaction? transaction = null)
        {
            return DeleteFromDb(tablename, id, "ID", transaction);
        }

        // New function that allows to delete rows from tables with arbitrary identifier values (e.g. ID or BackupID)
        private bool DeleteFromDb(string tablename, long id, string identifier, IDbTransaction? transaction = null)
        {
            if (transaction == null)
            {
                using (var tr = m_connection.BeginTransaction())
                {
                    var r = DeleteFromDb(tablename, id, tr);
                    tr.Commit();
                    return r;
                }
            }
            else
            {
                using (var cmd = m_connection.CreateCommand(transaction))
                {
                    cmd.SetCommandAndParameters(string.Format(CultureInfo.InvariantCulture, @"DELETE FROM ""{0}"" WHERE ""{1}""=@Value", tablename, identifier))
                        .SetParameterValue("@Value", id);

                    var r = cmd.ExecuteNonQuery();
                    // Roll back the transaction if more than 1 ID was deleted. Multiple "BackupID" rows being deleted isn't a problem.
                    if (identifier == "ID" && r > 1)
                        throw new Exception(string.Format("Too many records attempted deleted from table {0} for id {1}: {2}", tablename, id, r));
                    return r == 1;
                }
            }
        }

        private static IEnumerable<T> Read<T>(IDbCommand cmd, Func<IDataReader, T> f)
        {
            using (var rd = cmd.ExecuteReader())
                while (rd.Read())
                    yield return f(rd);
        }

        private static IEnumerable<T> Read<T>(IDataReader rd, Func<T> f)
        {
            while (rd.Read())
                yield return f();
        }

        private System.Reflection.PropertyInfo[] GetORMFields<T>()
        {
            var flags =
                System.Reflection.BindingFlags.FlattenHierarchy |
                System.Reflection.BindingFlags.Instance |
                System.Reflection.BindingFlags.Public;

            var supportedPropertyTypes = new Type[] {
                typeof(long),
                typeof(string),
                typeof(bool),
                typeof(DateTime)
            };

            return
                (from n in typeof(T).GetProperties(flags)
                 where supportedPropertyTypes.Contains(n.PropertyType) || n.PropertyType.IsEnum
                 select n).ToArray();
        }

        private IEnumerable<T> ReadFromDb<T>(Action<IDbCommand>? prep)
        {
            var properties = GetORMFields<T>();

            var sql = string.Format(CultureInfo.InvariantCulture,
                @"SELECT ""{0}"" FROM ""{1}""",
                string.Join(@""", """, properties.Select(x => x.Name)),
                typeof(T).Name
            );

            return ReadFromDb((rd) =>
            {
                var item = Activator.CreateInstance<T>();
                for (var i = 0; i < properties.Length; i++)
                {
                    var prop = properties[i];

                    if (prop.PropertyType.IsEnum)
                        prop.SetValue(item, ConvertToEnum(prop.PropertyType, rd, i, Enum.GetValues(prop.PropertyType).GetValue(0)), null);
                    else if (prop.PropertyType == typeof(string))
                        prop.SetValue(item, ConvertToString(rd, i), null);
                    else if (prop.PropertyType == typeof(long))
                        prop.SetValue(item, ConvertToInt64(rd, i), null);
                    else if (prop.PropertyType == typeof(bool))
                        prop.SetValue(item, ConvertToBoolean(rd, i), null);
                    else if (prop.PropertyType == typeof(DateTime))
                        prop.SetValue(item, ConvertToDateTime(rd, i), null);
                }

                return item;
            },
            cmd =>
            {
                cmd.SetCommandAndParameters(sql);
                if (prep != null)
                    prep(cmd);
            });
        }

        private void OverwriteAndUpdateDb<T>(IDbTransaction? transaction, Action<IDbCommand>? deletePrep, IEnumerable<T> values, bool updateExisting)
        {
            var properties = GetORMFields<T>();
            var idfield = properties.FirstOrDefault(x => x.Name == "ID")
                ?? throw new Exception("No ID field found in type " + typeof(T).Name);

            var nonIdProps = properties.Where(x => x.Name != "ID").ToArray();

            string sql;

            if (updateExisting)
            {
                sql = string.Format(
                    @"UPDATE ""{0}"" SET {1} WHERE ""ID""= @Id",
                    typeof(T).Name,
                    string.Join(@", ", nonIdProps.Select(x => @$"""{x.Name}"" = @{x.Name}"))
                );

                properties = properties.Append(idfield).ToArray();
            }
            else
            {

                sql = string.Format(
                    @"INSERT INTO ""{0}"" (""{1}"") VALUES ({2})",
                    typeof(T).Name,
                    string.Join(@""", """, nonIdProps.Select(x => x.Name)),
                    string.Join(@", ", nonIdProps.Select(x => $"@{x.Name}"))
                );
            }

            OverwriteAndUpdateDb(transaction, deletePrep, values,
                cmd => cmd.SetCommandAndParameters(sql),
                (cmd, item) =>
                {
                    foreach (var p in properties)
                    {
                        if (!updateExisting && p == idfield)
                            continue;

                        var val = p.GetValue(item, null);
                        if (val != null)
                        {
                            if (p.PropertyType.IsEnum)
                                val = val.ToString();
                            else if (p.PropertyType == typeof(DateTime))
                                val = Library.Utility.Utility.NormalizeDateTimeToEpochSeconds((DateTime)val);
                        }

                        cmd.SetParameterValue($"@{p.Name}", val);
                    }
                });

            if (!updateExisting && values.Count() == 1 && idfield != null)
                using (var cmd = m_connection.CreateCommand(transaction))
                {
                    cmd.SetCommandAndParameters(@"SELECT last_insert_rowid();");
                    if (idfield.PropertyType == typeof(string))
                        idfield.SetValue(values.First(), ExecuteScalarString(cmd), null);
                    else
                        idfield.SetValue(values.First(), ExecuteScalarInt64(cmd), null);
                }
        }

        private IEnumerable<T> ReadFromDb<T>(Func<IDataReader, T> f, Action<IDbCommand>? prep)
        {
            using (var cmd = m_connection.CreateCommand())
            {
                if (prep != null)
                    prep(cmd);
                return Read(cmd, f).ToArray();
            }
        }

        private void OverwriteAndUpdateDb<T>(IDbTransaction? transaction, Action<IDbCommand>? deletePrep, IEnumerable<T> values, Action<IDbCommand> insertPrep, Action<IDbCommand, T> insert)
        {
            using (var cmd = m_connection.CreateCommand(transaction))
            {
                if (deletePrep != null)
                {
                    deletePrep(cmd);
                    cmd.ExecuteNonQuery();
                }

                insertPrep(cmd);
                foreach (var v in values)
                {
                    insert(cmd, v);
                    cmd.ExecuteNonQuery();
                }
            }
        }

        /// <summary>
        /// Encrypts sensitive fields
        /// </summary>
        /// <param name="fieldName">The fieldname used to determine if it will be encrypted</param>
        /// <param name="fieldValue">The field value</param>
        /// <param name="key">The encryption key</param>
        /// <returns>The encrypted string or the original value</returns>
        private static string? EncryptSensitiveFields(string fieldName, string fieldValue, EncryptedFieldHelper.KeyInstance? key)
        {
            if (fieldValue != null)
                return _encryptedFields.Contains(fieldName)
                    ? EncryptedFieldHelper.Encrypt(fieldValue, key)
                    : fieldValue;

            return null;
        }

        /// <summary>
        /// Decrypts sensitive fields
        /// </summary>
        /// <param name="fieldValue">The field value</param>
        /// <param name="key">The encryption key</param>
        /// <returns>The decrypted string</returns>
        private static string? DecryptSensitiveFields(string? fieldValue, EncryptedFieldHelper.KeyInstance? key)
        {
            if (fieldValue != null)
                return EncryptedFieldHelper.IsEncryptedString(fieldValue)
                    ? EncryptedFieldHelper.Decrypt(fieldValue, key)
                    : fieldValue;

            return null;
        }

        public ConnectionString[] GetConnectionStrings()
        {
            lock (m_lock)
            {
                return ReadFromDb(cmd =>
                {
                    var cs = new ConnectionString();
                    cs.ID = cmd.GetInt64(0);
                    cs.Name = cmd.GetString(1);
                    cs.Description = cmd.GetString(2);
                    cs.BaseUrl = DecryptSensitiveFields(cmd.GetString(3), m_key) ?? "";
                    cs.CreatedAt = Library.Utility.Utility.EPOCH.AddSeconds(cmd.GetInt64(4));
                    cs.UpdatedAt = Library.Utility.Utility.EPOCH.AddSeconds(cmd.GetInt64(5));
                    return cs;
                }, cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Name"", ""Description"", ""BaseUrl"", ""CreatedAt"", ""UpdatedAt"" FROM ""ConnectionString""")).ToArray();
            }
        }

        public ConnectionString? GetConnectionString(long id)
        {
            lock (m_lock)
            {
                return ReadFromDb(cmd =>
                {
                    var cs = new ConnectionString();
                    cs.ID = cmd.GetInt64(0);
                    cs.Name = cmd.GetString(1);
                    cs.Description = cmd.GetString(2);
                    cs.BaseUrl = DecryptSensitiveFields(cmd.GetString(3), m_key) ?? "";
                    cs.CreatedAt = Library.Utility.Utility.EPOCH.AddSeconds(cmd.GetInt64(4));
                    cs.UpdatedAt = Library.Utility.Utility.EPOCH.AddSeconds(cmd.GetInt64(5));
                    return cs;
                }, cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Name"", ""Description"", ""BaseUrl"", ""CreatedAt"", ""UpdatedAt"" FROM ""ConnectionString"" WHERE ""ID"" = @ID").SetParameterValue("@ID", id)).FirstOrDefault();
            }
        }

        public void AddConnectionString(ConnectionString cs)
        {
            lock (m_lock)
            {
                cs.CreatedAt = DateTime.UtcNow;
                cs.UpdatedAt = DateTime.UtcNow;
                if (UrlContainsPasswordPlaceholder(cs.BaseUrl))
                    throw new ArgumentException("Connection string base URL contains password placeholder");

                var encryptedUrl = m_encryptSensitiveFields ? EncryptedFieldHelper.Encrypt(cs.BaseUrl, m_key) : cs.BaseUrl;

                using (var cmd = m_connection.CreateCommand())
                {
                    cmd.SetCommandAndParameters(@"INSERT INTO ""ConnectionString"" (""Name"", ""Description"", ""BaseUrl"", ""CreatedAt"", ""UpdatedAt"") VALUES (@Name, @Description, @BaseUrl, @CreatedAt, @UpdatedAt)");
                    cmd.SetParameterValue("@Name", cs.Name);
                    cmd.SetParameterValue("@Description", cs.Description);
                    cmd.SetParameterValue("@BaseUrl", encryptedUrl);
                    cmd.SetParameterValue("@CreatedAt", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(cs.CreatedAt));
                    cmd.SetParameterValue("@UpdatedAt", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(cs.UpdatedAt));
                    cmd.ExecuteNonQuery();
                    cs.ID = cmd.ExecuteScalarInt64(@"SELECT last_insert_rowid();");
                }
            }
        }

        public void UpdateConnectionString(ConnectionString cs)
        {
            lock (m_lock)
            {
                cs.UpdatedAt = DateTime.UtcNow;
                if (UrlContainsPasswordPlaceholder(cs.BaseUrl))
                    throw new ArgumentException("Connection string base URL contains password placeholder");

                var encryptedUrl = m_encryptSensitiveFields ? EncryptedFieldHelper.Encrypt(cs.BaseUrl, m_key) : cs.BaseUrl;

                using (var cmd = m_connection.CreateCommand())
                {
                    cmd.SetCommandAndParameters(@"UPDATE ""ConnectionString"" SET ""Name"" = @Name, ""Description"" = @Description, ""BaseUrl"" = @BaseUrl, ""UpdatedAt"" = @UpdatedAt WHERE ""ID"" = @ID");
                    cmd.SetParameterValue("@Name", cs.Name);
                    cmd.SetParameterValue("@Description", cs.Description);
                    cmd.SetParameterValue("@BaseUrl", encryptedUrl);
                    cmd.SetParameterValue("@UpdatedAt", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(cs.UpdatedAt));
                    cmd.SetParameterValue("@ID", cs.ID);
                    cmd.ExecuteNonQuery();
                }
            }
        }

        public void DeleteConnectionString(long id)
        {
            lock (m_lock)
            {
                using (var cmd = m_connection.CreateCommand())
                {
                    cmd.SetCommandAndParameters(@"DELETE FROM ""ConnectionString"" WHERE ""ID"" = @ID");
                    cmd.SetParameterValue("@ID", id);
                    cmd.ExecuteNonQuery();
                }
            }
        }

        public (string ID, string Name)[] GetBackupsUsingConnectionString(long id)
        {
            lock (m_lock)
            {
                return ReadFromDb(
                    cmd => (cmd.GetInt64(0).ToString(), cmd.GetString(1)),
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""Name"" FROM ""Backup"" WHERE ""ConnectionStringID"" = @ID").SetParameterValue("@ID", id)
                ).ToArray();
            }
        }

        /// <summary>
        /// Gets the additional target URLs for a backup
        /// </summary>
        /// <param name="backupId">The backup ID</param>
        /// <returns>The list of additional target URLs</returns>
        internal List<TargetUrlEntry> GetBackupTargetUrls(long backupId)
        {
            lock (m_lock)
            {
                return ReadFromDb(
                    (rd) => new TargetUrlEntry()
                    {
                        ID = ConvertToInt64(rd, 0),
                        BackupID = ConvertToInt64(rd, 1),
                        TargetUrlKey = ConvertToString(rd, 2) ?? "",
                        TargetUrl = EncryptedFieldHelper.Decrypt(ConvertToString(rd, 3) ?? "", m_key),
                        Mode = ConvertToString(rd, 4) ?? "inline",
                        Interval = ConvertToString(rd, 5),
                        ConnectionStringID = ConvertToInt64(rd, 6),
                        Options = ParseOptionsJson(ConvertToString(rd, 7)),
                        CreatedAt = ConvertToDateTime(rd, 8),
                        UpdatedAt = ConvertToDateTime(rd, 9)
                    },
                    cmd => cmd.SetCommandAndParameters(@"SELECT ""ID"", ""BackupID"", ""TargetUrlKey"", ""TargetURL"", ""Mode"", ""Interval"", ""ConnectionStringID"", ""Options"", ""CreatedAt"", ""UpdatedAt"" FROM ""BackupTargetUrl"" WHERE ""BackupID"" = @Id")
                        .SetParameterValue("@Id", backupId))
                    .ToList();
            }
        }

        /// <summary>
        /// Parses the options JSON string into a dictionary
        /// </summary>
        /// <param name="json">The JSON string</param>
        /// <returns>The dictionary or null if empty</returns>
        private static Dictionary<string, object>? ParseOptionsJson(string? json)
        {
            try
            {
                if (!string.IsNullOrWhiteSpace(json))
                    return JsonSerializer.Deserialize<Dictionary<string, object>>(json);
            }
            catch
            {
            }

            return null;
        }

        /// <summary>
        /// Sets the additional target URLs for a backup
        /// </summary>
        /// <param name="backupId">The backup ID</param>
        /// <param name="targets">The target URLs to set</param>
        /// <param name="transaction">The database transaction</param>
        internal void SetBackupTargetUrls(long backupId, IEnumerable<TargetUrlEntry> targets, IDbTransaction? transaction = null)
        {
            lock (m_lock)
            {
                var tr = transaction ?? m_connection.BeginTransaction();
                try
                {
                    // Delete existing entries
                    using (var cmd = m_connection.CreateCommand(tr))
                        cmd.SetCommandAndParameters(@"DELETE FROM ""BackupTargetUrl"" WHERE ""BackupID"" = @Id")
                            .SetParameterValue("@Id", backupId)
                            .ExecuteNonQuery();

                    // Insert new entries
                    var now = DateTime.UtcNow;
                    foreach (var target in targets)
                    {
                        if (UrlContainsPasswordPlaceholder(target.TargetUrl))
                            throw new Exception("Attempted to save a target URL with the password placeholder");

                        var encryptedUrl = m_encryptSensitiveFields
                            ? EncryptedFieldHelper.Encrypt(target.TargetUrl, m_key)
                            : target.TargetUrl;

                        var optionsJson = target.Options != null
                            ? JsonSerializer.Serialize(target.Options)
                            : null;

                        using (var cmd = m_connection.CreateCommand(tr))
                        {
                            cmd.SetCommandAndParameters(@"INSERT INTO ""BackupTargetUrl"" (""BackupID"", ""TargetUrlKey"", ""TargetURL"", ""Mode"", ""Interval"", ""ConnectionStringID"", ""Options"", ""CreatedAt"", ""UpdatedAt"") VALUES (@BackupId, @TargetUrlKey, @TargetUrl, @Mode, @Interval, @ConnectionStringID, @Options, @CreatedAt, @UpdatedAt)");
                            cmd.SetParameterValue("@BackupId", backupId);
                            cmd.SetParameterValue("@TargetUrlKey", target.TargetUrlKey);
                            cmd.SetParameterValue("@TargetUrl", encryptedUrl);
                            cmd.SetParameterValue("@Mode", target.Mode);
                            cmd.SetParameterValue("@Interval", target.Interval);
                            cmd.SetParameterValue("@ConnectionStringID", target.ConnectionStringID);
                            cmd.SetParameterValue("@Options", optionsJson);
                            cmd.SetParameterValue("@CreatedAt", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(now));
                            cmd.SetParameterValue("@UpdatedAt", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(now));
                            cmd.ExecuteNonQuery();
                        }
                    }

                    if (transaction == null)
                    {
                        tr.Commit();
                        tr.Dispose();
                    }
                }
                catch
                {
                    if (transaction == null)
                        tr.Dispose();
                    throw;
                }
            }
        }

        #region IDisposable implementation
        public void Dispose()
        {
            try { m_errorcmd?.Dispose(); }
            catch { }

            try { m_connection?.Dispose(); }
            catch { }
        }
        #endregion
    }

}

