//-----------------------------------------------------------------------
// <copyright file="DbService.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------

namespace AliasVault.Client.Services.Database;

using System.Data;
using System.Net.Http.Json;
using System.Text.Json;
using AliasClientDb;
using AliasClientDb.Models;
using AliasVault.Client.Services;
using AliasVault.Client.Services.Auth;
using AliasVault.Client.Services.JsInterop.Models;
using AliasVault.Client.Services.JsInterop.RustCore;
using AliasVault.Client.Utilities;
using AliasVault.Shared.Models.Enums;
using AliasVault.Shared.Models.WebApi.Vault;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;

/// <summary>
/// Class to manage the in-memory AliasClientDb service. The reason for this service is to provide a way to interact
/// with a AliasClientDb database instance that is only persisted in memory due to the encryption requirements of the
/// database itself. The database should not be persisted to disk when in un-encrypted form.
/// </summary>
public sealed class DbService : IDisposable
{
    private const string _UNKNOWN_VERSION = "Unknown";
    private readonly AuthService _authService;
    private readonly JsInteropService _jsInteropService;
    private readonly RustCoreService _rustCore;
    private readonly HttpClient _httpClient;
    private readonly DbServiceState _state = new();
    private readonly Config _config;
    private readonly ILogger<DbService> _logger;
    private readonly GlobalNotificationService _globalNotificationService;
    private readonly IStringLocalizer _sharedLocalizer;
    private SettingsService _settingsService = new();
    private SqliteConnection? _sqlConnection;
    private AliasClientDbContext _dbContext;
    private long _vaultRevisionNumber;
    private bool _isSuccessfullyInitialized;
    private int _retryCount;
    private bool _disposed;

    /// <summary>
    /// Initializes a new instance of the <see cref="DbService"/> class.
    /// </summary>
    /// <param name="authService">AuthService.</param>
    /// <param name="jsInteropService">JsInteropService.</param>
    /// <param name="rustCore">RustCoreService for WASM interop.</param>
    /// <param name="httpClient">HttpClient.</param>
    /// <param name="config">Config instance.</param>
    /// <param name="globalNotificationService">Global notification service.</param>
    /// <param name="localizerFactory">IStringLocalizerFactory instance.</param>
    /// <param name="logger">ILogger instance.</param>
    public DbService(AuthService authService, JsInteropService jsInteropService, RustCoreService rustCore, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, IStringLocalizerFactory localizerFactory, ILogger<DbService> logger)
    {
        _authService = authService;
        _jsInteropService = jsInteropService;
        _rustCore = rustCore;
        _httpClient = httpClient;
        _config = config;
        _globalNotificationService = globalNotificationService;
        _sharedLocalizer = localizerFactory.Create("SharedResources", "AliasVault.Client");
        _logger = logger;

        // Set the initial state of the database service.
        _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized);

        // Create an in-memory SQLite database connection which stays open for the lifetime of the service.
        (_sqlConnection, _dbContext) = InitializeEmptyDatabase();
    }

    /// <summary>
    /// Gets the settings service instance which can be used to interact with general settings stored in the database.
    /// </summary>
    /// <returns>SettingsService.</returns>
    public SettingsService Settings => _settingsService;

    /// <summary>
    /// Gets database service state object which can be subscribed to.
    /// </summary>
    /// <returns>DbServiceState instance.</returns>
    public DbServiceState GetState()
    {
        return _state;
    }

    /// <summary>
    /// Initializes the database, either by creating a new one or loading an existing one from the server.
    /// </summary>
    /// <returns>Task.</returns>
    public async Task InitializeDatabaseAsync()
    {
        // Check that encryption key is set. If not, do nothing.
        if (!_authService.IsEncryptionKeySet())
        {
            return;
        }

        // Attempt to fill the local database with a previously saved database stored on the server.
        var loaded = await LoadDatabaseFromServerAsync();
        if (loaded)
        {
            _retryCount = 0;
        }
    }

    /// <summary>
    /// Stores / updates the vault revision number. Should be called after a successful vault update to the server.
    /// </summary>
    /// <param name="newRevisionNumber">New revision number.</param>
    public void StoreVaultRevisionNumber(long newRevisionNumber)
    {
        _vaultRevisionNumber = newRevisionNumber;
    }

    /// <summary>
    /// Returns the AliasClientDbContext instance.
    /// </summary>
    /// <returns>AliasClientDbContext.</returns>
    public async Task<AliasClientDbContext> GetDbContextAsync()
    {
        if (!_isSuccessfullyInitialized)
        {
            // Retry initialization up to 5 times before giving up.
            if (_retryCount < 5)
            {
                _retryCount++;
                await InitializeDatabaseAsync();
            }
            else
            {
                throw new DataException("Failed to initialize database.");
            }
        }

        return _dbContext;
    }

    /// <summary>
    /// Generate encrypted base64 string representation of current state of database in order to save it
    /// to the server.
    /// </summary>
    /// <returns>Base64 encoded vault blob.</returns>
    public async Task<string> GetEncryptedDatabaseBase64String()
    {
        // Save the actual dbContext.
        await _dbContext.SaveChangesAsync();

        string base64String = await ExportSqliteToBase64Async();

        // SymmetricEncrypt base64 string using IJSInterop.
        return await _jsInteropService.SymmetricEncrypt(base64String, _authService.GetEncryptionKeyAsBase64Async());
    }

    /// <summary>
    /// Saves the database to the remote server.
    /// </summary>
    /// <returns>Bool which indicates if saving database to server was successful.</returns>
    public async Task<bool> SaveDatabaseAsync()
    {
        if (_state.CurrentState.Status != DbServiceState.DatabaseStatus.Creating)
        {
            // If database is not in the process of being created, update status to saving which is reflected in the UI.
            _state.UpdateState(DbServiceState.DatabaseStatus.SavingToServer);
        }

        // Prune expired items from trash before saving.
        await PruneExpiredTrashItemsAsync();

        // Make sure a public/private RSA encryption key exists before saving the database.
        await GetOrCreateEncryptionKeyAsync();

        var encryptedBase64String = await GetEncryptedDatabaseBase64String();

        // Save to webapi.
        var success = await SaveToServerAsync(encryptedBase64String);
        if (success)
        {
            _logger.LogInformation("Database successfully saved to server.");
            if (_state.CurrentState.Status != DbServiceState.DatabaseStatus.Creating)
            {
                // If database is not in the process of being created, update status to ready which is reflected in the UI.
                _state.UpdateState(DbServiceState.DatabaseStatus.Ready);
            }
        }

        return success;
    }

    /// <summary>
    /// Saves the database to the remote server in the background without blocking the caller.
    /// The local database state is immediately persisted (in-memory), and the server sync happens asynchronously.
    /// If the sync fails, a notification is shown to the user.
    /// </summary>
    /// <remarks>
    /// This method is useful for operations where blocking the UI is undesirable, such as
    /// folder creation, settings changes, etc. The local mutation is considered immediately
    /// successful, and server sync happens in the background.
    /// </remarks>
    public void SaveDatabaseInBackground()
    {
        // Set state to indicate background sync is pending
        _state.UpdateState(DbServiceState.DatabaseStatus.BackgroundSyncPending);

        // Fire and forget the background save operation
        _ = Task.Run(async () =>
        {
            try
            {
                // Prune expired items from trash before saving.
                await PruneExpiredTrashItemsAsync();

                // Make sure a public/private RSA encryption key exists before saving the database.
                await GetOrCreateEncryptionKeyAsync();

                var encryptedBase64String = await GetEncryptedDatabaseBase64String();

                // Update state to show we're actively syncing
                _state.UpdateState(DbServiceState.DatabaseStatus.SavingToServer);

                // Save to webapi.
                var success = await SaveToServerAsync(encryptedBase64String);
                if (success)
                {
                    _logger.LogInformation("Database successfully saved to server (background sync).");
                    _state.UpdateState(DbServiceState.DatabaseStatus.Ready);
                }
                else
                {
                    _logger.LogWarning("Background sync to server failed.");
                    _globalNotificationService.AddErrorMessage(
                        "Failed to sync changes to server. Your changes are saved locally and will be synced on next refresh.");
                    _state.UpdateState(DbServiceState.DatabaseStatus.Ready);
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error during background database sync.");
                _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]);
                _state.UpdateState(DbServiceState.DatabaseStatus.Ready);
            }
        });
    }

    /// <summary>
    /// Export the in-memory SQLite database to a base64 string.
    /// </summary>
    /// <returns>Base64 encoded string that represents SQLite database.</returns>
    public async Task<string> ExportSqliteToBase64Async()
    {
        var tempFileName = Path.GetRandomFileName();

        // Export SQLite memory database to a temp file.
        using var memoryStream = new MemoryStream();
        await using var command = _sqlConnection!.CreateCommand();
        command.CommandText = "VACUUM main INTO @fileName";
        command.Parameters.Add(new SqliteParameter("@fileName", tempFileName));
        await command.ExecuteNonQueryAsync();

        // Get bytes.
        var bytes = await File.ReadAllBytesAsync(tempFileName);
        string base64String = Convert.ToBase64String(bytes);

        // Delete temp file.
        File.Delete(tempFileName);

        return base64String;
    }

    /// <summary>
    /// Creates a new vault with the latest schema.
    /// </summary>
    /// <returns>Bool which indicates if creating a new vault was successful.</returns>
    public async Task<bool> CreateNewVaultAsync()
    {
        try
        {
            // Call JS interop to get SQL commands to create a new vault with the latest schema.
            var sqlCommands = await _jsInteropService.GetCreateVaultSqlAsync();

            // Execute the SQL commands to create a new vault with the latest schema.
            foreach (var sqlCommand in sqlCommands.SqlCommands)
            {
                await _dbContext.Database.ExecuteSqlRawAsync(sqlCommand);
            }

            // Init settings service.
            _isSuccessfullyInitialized = true;
            await _settingsService.InitializeAsync(this);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating new vault.");
            return false;
        }

        return true;
    }

    /// <summary>
    /// Migrate the database structure to the latest version.
    /// </summary>
    /// <returns>Bool which indicates if migration was successful.</returns>
    public async Task<bool> MigrateDatabaseAsync()
    {
        try
        {
            // Get current version of database.
            var currentVersion = await GetCurrentDatabaseVersionAsync();

            // Get latest version from JsInteropService.
            var latestVersion = await _jsInteropService.GetLatestVaultVersionAsync();

            // Call JS interop to get SQL commands to create a new vault with the latest schema.
            var sqlCommands = await _jsInteropService.GetUpgradeVaultSqlAsync(currentVersion.Revision, latestVersion.Revision);

            // Execute the SQL commands to create a new vault with the latest schema.
            foreach (var sqlCommand in sqlCommands.SqlCommands)
            {
                await _dbContext.Database.ExecuteSqlRawAsync(sqlCommand);
            }

            _isSuccessfullyInitialized = true;
            await _settingsService.InitializeAsync(this);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error migrating database.");
            return false;
        }

        return true;
    }

    /// <summary>
    /// Get the current version (applied migration) of the database that is loaded in memory.
    /// Uses semantic versioning to allow backwards-compatible minor/patch versions.
    /// </summary>
    /// <returns>Version as string.</returns>
    public async Task<SqlVaultVersion> GetCurrentDatabaseVersionAsync()
    {
        var migrations = await _dbContext.Database.GetAppliedMigrationsAsync();
        var lastMigration = migrations.LastOrDefault();
        var currentVersion = _UNKNOWN_VERSION;

        // Convert migration Id in the form of "20240708094944_1.0.0-InitialMigration" to "1.0.0".
        if (lastMigration is not null)
        {
            var parts = lastMigration.Split('_');
            if (parts.Length > 1)
            {
                var versionPart = parts[1].Split('-')[0];
                if (Version.TryParse(versionPart, out _))
                {
                    currentVersion = versionPart;
                }
            }
        }

        // Check version compatibility using semantic versioning
        var isCompatible = await _jsInteropService.IsVersionCompatibleAsync(currentVersion);

        if (!isCompatible)
        {
            // Version is incompatible (different major version)
            return new SqlVaultVersion
            {
                Revision = 0,
                Version = _UNKNOWN_VERSION,
                Description = _UNKNOWN_VERSION,
                ReleaseVersion = _UNKNOWN_VERSION,
                CompatibleUpToVersion = _UNKNOWN_VERSION,
            };
        }

        // Get all available vault versions to get the revision number of the current version.
        var allVersions = await _jsInteropService.GetAllVaultVersionsAsync();
        var currentVersionRevision = allVersions.FirstOrDefault(v => v.Version == currentVersion);

        // If the version is known, return it
        if (currentVersionRevision is not null)
        {
            return currentVersionRevision;
        }

        /*
         * Version is unknown but compatible (same major version).
         * Create a version object with the actual database version but use the latest client's revision number.
         * This allows older clients to work with newer backwards-compatible database versions.
         */
        var latestClientVersion = await _jsInteropService.GetLatestVaultVersionAsync();

        // Return a version object with the actual database version string but the latest known revision
        return new SqlVaultVersion
        {
            Revision = latestClientVersion.Revision,
            Version = currentVersion, // Use the actual database version (e.g., "1.7.0")
            Description = $"Unknown version {currentVersion} (backwards compatible)",
            ReleaseVersion = latestClientVersion.ReleaseVersion,
            CompatibleUpToVersion = latestClientVersion.CompatibleUpToVersion,
        };
    }

    /// <summary>
    /// Get the latest available version (EF migration) as defined in code.
    /// </summary>
    /// <returns>Version as string.</returns>
    public async Task<SqlVaultVersion> GetLatestDatabaseVersionAsync()
    {
        var allVersions = await _jsInteropService.GetAllVaultVersionsAsync();
        var latestVersion = allVersions.LastOrDefault();

        return latestVersion ?? new SqlVaultVersion
        {
            Revision = 0,
            Version = _UNKNOWN_VERSION,
            Description = _UNKNOWN_VERSION,
            ReleaseVersion = _UNKNOWN_VERSION,
            CompatibleUpToVersion = _UNKNOWN_VERSION,
        };
    }

    /// <summary>
    /// Prepare a vault object for upload to the server.
    /// </summary>
    /// <param name="encryptedDatabase">Encrypted database as string.</param>
    /// <returns>Vault object.</returns>
    public async Task<Vault> PrepareVaultForUploadAsync(string encryptedDatabase)
    {
        var username = _authService.GetUsername();
        var databaseVersion = await GetCurrentDatabaseVersionAsync();
        var encryptionKey = await GetOrCreateEncryptionKeyAsync();
        var credentialsCount = await _dbContext.Items.Where(x => !x.IsDeleted && x.DeletedAt == null).CountAsync();
        var emailAddresses = await GetEmailClaimListAsync();
        var currentDateTime = DateTime.UtcNow;
        return new Vault
        {
            Username = username,
            Blob = encryptedDatabase,
            Version = databaseVersion.Version,
            CurrentRevisionNumber = _vaultRevisionNumber,
            EncryptionPublicKey = encryptionKey.PublicKey,
            CredentialsCount = credentialsCount,
            EmailAddressList = emailAddresses,
            PrivateEmailDomainList = [],
            HiddenPrivateEmailDomainList = [],
            PublicEmailDomainList = [],
            CreatedAt = currentDateTime,
            UpdatedAt = currentDateTime,
        };
    }

    /// <summary>
    /// Clears the database connection and creates a new one so that the database is empty.
    /// </summary>
    /// <returns>SqliteConnection and AliasClientDbContext.</returns>
    public (SqliteConnection SqliteConnection, AliasClientDbContext AliasClientDbContext) InitializeEmptyDatabase()
    {
        if (_sqlConnection?.State == ConnectionState.Open)
        {
            _sqlConnection.Close();
            _sqlConnection.Dispose();
        }

        _sqlConnection = new SqliteConnection("Data Source=:memory:");
        _sqlConnection.Open();

        _dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log));

        // Reset the database state.
        _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized);
        _isSuccessfullyInitialized = false;

        // Reset settings.
        _settingsService = new();

        return (_sqlConnection, _dbContext);
    }

    /// <summary>
    /// Get a list of private email addresses that are used in items by this vault.
    /// </summary>
    /// <returns>List of email addresses.</returns>
    public async Task<List<string>> GetEmailClaimListAsync()
    {
        // Send list of email addresses that are used in items by this vault, so they can be
        // claimed on the server.
        var emailAddresses = await _dbContext.FieldValues
            .Where(fv => fv.FieldKey == FieldKey.LoginEmail)
            .Where(fv => fv.Value != null)
            .Where(fv => !fv.IsDeleted)
            .Where(fv => !fv.Item.IsDeleted && fv.Item.DeletedAt == null)
            .Select(fv => fv.Value)
            .Distinct()
            .Select(email => email!)
            .ToListAsync();

        if (_config.PrivateEmailDomains.Count == 0)
        {
            return [];
        }

        if (_config.PrivateEmailDomains.Count == 1)
        {
            if (string.IsNullOrWhiteSpace(_config.PrivateEmailDomains[0]))
            {
                return [];
            }

            // TODO: "DISABLED.TLD" was a placeholder used < 0.22.0 that has been replaced by an empty string.
            // That value is still here for legacy purposes, but it can be removed from the codebase in a future release.
            if (_config.PrivateEmailDomains[0] == "DISABLED.TLD")
            {
                return [];
            }
        }

        // Filter the list of email addresses to only include those that are in the supported private email domains.
        return emailAddresses.Where(email => _config.PrivateEmailDomains.Exists(domain => email.EndsWith(domain))).ToList();
    }

    /// <summary>
    /// Implements the IDisposable interface.
    /// </summary>
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Loads a SQLite database from a base64 string which represents a .sqlite file.
    /// </summary>
    /// <param name="base64String">Base64 string representation of a .sqlite file.</param>
    /// <param name="connection">The connection to the database that should be used for the import.</param>
    private static async Task ImportDbContextFromBase64Async(string base64String, SqliteConnection connection)
    {
        var bytes = Convert.FromBase64String(base64String);
        var tempFileName = Path.GetRandomFileName();
        await File.WriteAllBytesAsync(tempFileName, bytes);

        await using (var command = connection.CreateCommand())
        {
            // Disable foreign key constraints
            command.CommandText = "PRAGMA foreign_keys = OFF;";
            await command.ExecuteNonQueryAsync();

            // Drop all tables in the original database
            command.CommandText = @"
                SELECT 'DROP TABLE IF EXISTS ' || name || ';'
                FROM sqlite_master
                WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
            var dropTableCommands = new List<string>();
            await using (var reader = await command.ExecuteReaderAsync())
            {
                while (await reader.ReadAsync())
                {
                    dropTableCommands.Add(reader.GetString(0));
                }
            }

            foreach (var dropTableCommand in dropTableCommands)
            {
                command.CommandText = dropTableCommand;
                await command.ExecuteNonQueryAsync();
            }

            // Attach the imported database
            command.CommandText = "ATTACH DATABASE @fileName AS importDb";
            command.Parameters.Add(new SqliteParameter("@fileName", tempFileName));
            await command.ExecuteNonQueryAsync();

            // Get CREATE TABLE statements from the imported database
            command.CommandText = @"
                SELECT sql
                FROM importDb.sqlite_master
                WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
            var createTableCommands = new List<string>();
            await using (var reader = await command.ExecuteReaderAsync())
            {
                while (await reader.ReadAsync())
                {
                    createTableCommands.Add(reader.GetString(0));
                }
            }

            // Create tables in the main database
            foreach (var createTableCommand in createTableCommands)
            {
                command.CommandText = createTableCommand;
                await command.ExecuteNonQueryAsync();
            }

            // Copy data from imported database to main database
            command.CommandText = @"
                SELECT 'INSERT INTO main.' || name || ' SELECT * FROM importDb.' || name || ';'
                FROM importDb.sqlite_master
                WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
            var tableInsertCommands = new List<string>();
            await using (var reader = await command.ExecuteReaderAsync())
            {
                while (await reader.ReadAsync())
                {
                    tableInsertCommands.Add(reader.GetString(0));
                }
            }

            foreach (var tableInsertCommand in tableInsertCommands)
            {
                command.CommandText = tableInsertCommand;
                await command.ExecuteNonQueryAsync();
            }

            // Detach the imported database
            command.CommandText = "DETACH DATABASE importDb";
            await command.ExecuteNonQueryAsync();

            // Re-enable foreign key constraints
            command.CommandText = "PRAGMA foreign_keys = ON;";
            await command.ExecuteNonQueryAsync();
        }

        File.Delete(tempFileName);
    }

    /// <summary>
    /// Converts a JsonElement to its appropriate .NET value for SQLite parameters.
    /// </summary>
    /// <param name="element">The JsonElement to convert.</param>
    /// <returns>The converted value.</returns>
    private static object? ConvertJsonElementToValue(JsonElement element)
    {
        return element.ValueKind switch
        {
            JsonValueKind.String => element.GetString(),
            JsonValueKind.Number => element.TryGetInt64(out var longVal) ? longVal : element.GetDouble(),
            JsonValueKind.True => 1L, // SQLite stores booleans as integers
            JsonValueKind.False => 0L,
            JsonValueKind.Null => null,
            JsonValueKind.Undefined => null,
            _ => element.ToString(),
        };
    }

    /// <summary>
    /// Replace first occurrence of a string.
    /// </summary>
    /// <param name="text">The text to search in.</param>
    /// <param name="search">The string to search for.</param>
    /// <param name="replace">The replacement string.</param>
    /// <returns>The modified string.</returns>
    private static string ReplaceFirst(string text, string search, string replace)
    {
        int pos = text.IndexOf(search, StringComparison.Ordinal);
        if (pos < 0)
        {
            return text;
        }

        return text[..pos] + replace + text[(pos + search.Length)..];
    }

    /// <summary>
    /// Fetches the latest vault from server, merges with local changes using Rust WASM, and saves the merged result.
    /// Called when server responds with "Outdated" status, indicating another client has uploaded a newer vault.
    /// </summary>
    /// <returns>Bool which indicates if merge and save was successful.</returns>
    private async Task<bool> MergeWithServerAndSaveAsync()
    {
        try
        {
            _logger.LogInformation("Local vault is outdated. Fetching latest vault from server for merge...");

            // Fetch the latest vault from server.
            var response = await _httpClient.GetFromJsonAsync<VaultGetResponse>("v1/Vault");
            if (response?.Vault == null || string.IsNullOrEmpty(response.Vault.Blob))
            {
                _logger.LogError("Failed to fetch vault from server for merge.");
                _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]);
                return false;
            }

            var serverVault = response.Vault;
            _logger.LogInformation("Fetched server vault at revision {Revision}.", serverVault.CurrentRevisionNumber);

            // Store username of the loaded vault in memory to send to server as sanity check when updating the vault later.
            _authService.StoreUsername(serverVault.Username);

            // Decrypt server vault.
            var decryptedBase64String = await _jsInteropService.SymmetricDecrypt(serverVault.Blob, _authService.GetEncryptionKeyAsBase64Async());

            // Get the list of syncable table names from Rust core.
            var tableNames = await _rustCore.GetSyncableTableNamesAsync();

            // Read local tables as JSON.
            var localTables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames);
            _logger.LogDebug("Read {Count} local tables.", localTables.Count);

            // Create a temporary in-memory SQLite database for the server vault.
            await using var serverConnection = new SqliteConnection("Data Source=:memory:");
            await serverConnection.OpenAsync();
            await ImportDbContextFromBase64Async(decryptedBase64String, serverConnection);

            // Read server tables as JSON.
            var serverTables = await ReadTablesAsJsonAsync(serverConnection, tableNames);
            _logger.LogDebug("Read {Count} server tables.", serverTables.Count);

            // Create the merge input (local has our pending changes, server has the latest).
            var mergeInput = new MergeInput
            {
                LocalTables = localTables,
                ServerTables = serverTables,
            };

            // Call Rust WASM merge (LWW - Last Write Wins based on UpdatedAt).
            var mergeOutput = await _rustCore.MergeVaultsAsync(mergeInput);

            _logger.LogInformation(
                "Merge completed: {TablesProcessed} tables, {FromLocal} kept local, {FromServer} from server, {Inserted} inserted, {Conflicts} conflicts.",
                mergeOutput.Stats.TablesProcessed,
                mergeOutput.Stats.RecordsFromLocal,
                mergeOutput.Stats.RecordsFromServer,
                mergeOutput.Stats.RecordsInserted,
                mergeOutput.Stats.Conflicts);

            // Execute the SQL statements returned by the merge to update local database.
            await ExecuteMergeSqlStatementsAsync(mergeOutput.Statements);

            // Verify foreign key integrity after merge.
            await using (var command = _sqlConnection!.CreateCommand())
            {
                command.CommandText = "PRAGMA foreign_key_check;";
                await using var reader = await command.ExecuteReaderAsync();
                if (await reader.ReadAsync())
                {
                    _logger.LogError("Foreign key violation detected after merge.");
                    _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]);
                    return false;
                }
            }

            // Update the db context with the merged database.
            _dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log));

            // Update the local revision number to the server's revision.
            // When we upload, server will calculate new revision = this + 1.
            StoreVaultRevisionNumber(serverVault.CurrentRevisionNumber);

            _logger.LogInformation("Local merge completed. Uploading merged vault to server...");

            // Now save the merged vault to server. This recursive call handles the case where
            // another client uploaded during our merge (returns Outdated again).
            return await SaveDatabaseAsync();
        }
        catch (Exception ex)
        {
            _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]);
            _logger.LogError(ex, "Error merging with server vault.");
            return false;
        }
    }

    /// <summary>
    /// Checks if there are any pending migrations.
    /// </summary>
    /// <returns>Bool which indicates if there are any pending migrations.</returns>
    private async Task<bool> HasPendingMigrationsAsync()
    {
        // Get current version of database.
        var currentVersion = await GetCurrentDatabaseVersionAsync();
        if (currentVersion.Revision == 0)
        {
            // Revision 0 means current version could not be found because it's unknown
            // by the current client, most likely a newer version. Throw error.
            throw new DataException("Current vault version could not be determined.");
        }

        // Get latest version from JsInteropService.
        var latestVersion = await _jsInteropService.GetLatestVaultVersionAsync();

        return currentVersion.Revision < latestVersion.Revision;
    }

    /// <summary>
    /// Loads the database from the server.
    /// </summary>
    /// <returns>Task.</returns>
    private async Task<bool> LoadDatabaseFromServerAsync()
    {
        _state.UpdateState(DbServiceState.DatabaseStatus.Loading);
        _logger.LogInformation("Loading database from server...");

        // Load from webapi.
        try
        {
            var response = await _httpClient.GetFromJsonAsync<VaultGetResponse>("v1/Vault");
            if (response is not null)
            {
                var vault = response.Vault!;
                StoreVaultRevisionNumber(vault.CurrentRevisionNumber);

                // Store username of the loaded vault in memory to send to server as sanity check when updating the vault later.
                _authService.StoreUsername(vault.Username);

                // Check if vault blob is empty, if so, we don't need to do anything and the initial vault created
                // on client is sufficient.
                if (string.IsNullOrEmpty(vault.Blob))
                {
                    // Create the database structure from scratch to get an empty ready-to-use database.
                    _state.UpdateState(DbServiceState.DatabaseStatus.Creating);
                    return false;
                }

                // Attempt to decrypt the database blob.
                string decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
                await ImportDbContextFromBase64Async(decryptedBase64String, _sqlConnection!);

                // Refresh the db context with the new database to invalidate any cached data if the _dbContext was already used.
                _dbContext = new AliasClientDbContext(_sqlConnection!, log => _logger.LogDebug("{Message}", log));

                // Check if database is up-to-date with migrations.
                try
                {
                    if (await HasPendingMigrationsAsync())
                    {
                        _state.UpdateState(DbServiceState.DatabaseStatus.PendingMigrations);
                        return false;
                    }
                }
                catch (DataException)
                {
                    _state.UpdateState(DbServiceState.DatabaseStatus.VaultVersionUnrecognized);
                    return false;
                }

                _isSuccessfullyInitialized = true;
                await _settingsService.InitializeAsync(this);
                _state.UpdateState(DbServiceState.DatabaseStatus.Ready);
                return true;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading database from server.");
            _state.UpdateState(DbServiceState.DatabaseStatus.DecryptionFailed);
            return false;
        }

        return false;
    }

    /// <summary>
    /// Saves encrypted database blob to server and updates the local revision number.
    /// </summary>
    /// <param name="encryptedDatabase">Encrypted database as string.</param>
    /// <returns>True if save action succeeded and revision number was updated, false otherwise.</returns>
    private async Task<bool> SaveToServerAsync(string encryptedDatabase)
    {
        var vaultObject = await PrepareVaultForUploadAsync(encryptedDatabase);

        try
        {
            var response = await _httpClient.PostAsJsonAsync("v1/Vault", vaultObject);

            // Ensure the request was successful
            response.EnsureSuccessStatusCode();

            // Deserialize the response content
            var vaultUpdateResponse = await response.Content.ReadFromJsonAsync<VaultUpdateResponse>();

            if (vaultUpdateResponse != null)
            {
                if (vaultUpdateResponse.Status == VaultStatus.Outdated)
                {
                    // Server has a newer vault. Fetch it, merge with our local changes, and re-upload.
                    // The merge uses LWW (Last Write Wins) based on UpdatedAt timestamps.
                    return await MergeWithServerAndSaveAsync();
                }

                _vaultRevisionNumber = vaultUpdateResponse.NewRevisionNumber;
                return true;
            }

            _logger.LogError("Error during save: server response was empty or could not be deserialized.");
            return false;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error saving database to server.");
            return false;
        }
    }

    /// <summary>
    /// Prunes expired items from the trash.
    /// Items that have been in trash (DeletedAt set) for longer than 30 days
    /// are permanently deleted (IsDeleted = true).
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    private async Task PruneExpiredTrashItemsAsync()
    {
        try
        {
            // Read table data for prune operation
            var tableNames = new[] { "Items", "FieldValues", "Attachments", "TotpCodes", "Passkeys" };
            var tables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames);

            var pruneInput = new JsInterop.RustCore.PruneInput
            {
                Tables = tables,
                RetentionDays = 30,
                CurrentTime = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
            };

            var pruneOutput = await _rustCore.PruneVaultAsync(pruneInput);

            if (pruneOutput.Success && pruneOutput.Statements.Count > 0)
            {
                _logger.LogInformation("Pruning {StatementCount} expired items from trash.", pruneOutput.Statements.Count);

                // Execute the SQL statements returned by Rust
                foreach (var stmt in pruneOutput.Statements)
                {
                    await using var command = _sqlConnection!.CreateCommand();
                    command.CommandText = stmt.Sql;

                    for (int i = 0; i < stmt.Params.Count; i++)
                    {
                        var param = stmt.Params[i];
                        command.Parameters.AddWithValue($"@p{i}", param?.ToString() ?? (object)DBNull.Value);
                    }

                    // Replace ? placeholders with @p0, @p1, etc.
                    var parameterizedSql = stmt.Sql;
                    for (int i = 0; i < stmt.Params.Count; i++)
                    {
                        parameterizedSql = ReplaceFirst(parameterizedSql, "?", $"@p{i}");
                    }

                    command.CommandText = parameterizedSql;
                    await command.ExecuteNonQueryAsync();
                }
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to prune expired trash items. Continuing with save.");
        }
    }

    /// <summary>
    /// Get the default public/private encryption key, if it does not yet exist, create it.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    private async Task<EncryptionKey> GetOrCreateEncryptionKeyAsync()
    {
        var encryptionKey = await _dbContext.EncryptionKeys.FirstOrDefaultAsync(x => x.IsPrimary);
        if (encryptionKey is not null)
        {
            return encryptionKey;
        }

        // Create a new encryption key via JSInterop, .NET WASM does not support crypto operations natively (yet).
        var keyPair = await _jsInteropService.GenerateRsaKeyPair();

        var currentDateTime = DateTime.UtcNow;
        encryptionKey = new EncryptionKey
        {
            PublicKey = keyPair.PublicKey,
            PrivateKey = keyPair.PrivateKey,
            IsPrimary = true,
            CreatedAt = currentDateTime,
            UpdatedAt = currentDateTime,
        };
        _dbContext.EncryptionKeys.Add(encryptionKey);
        return encryptionKey;
    }

    /// <summary>
    /// Reads all specified tables from a SQLite connection as JSON data for the Rust merge.
    /// </summary>
    /// <param name="connection">The SQLite connection to read from.</param>
    /// <param name="tableNames">The names of tables to read.</param>
    /// <returns>List of TableData objects containing the table records.</returns>
    private async Task<List<TableData>> ReadTablesAsJsonAsync(SqliteConnection connection, string[] tableNames)
    {
        var tables = new List<TableData>();

        foreach (var tableName in tableNames)
        {
            var tableData = new TableData { Name = tableName };

            // Check if table exists in the database.
            await using var checkCommand = connection.CreateCommand();
            checkCommand.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=@tableName";
            checkCommand.Parameters.AddWithValue("@tableName", tableName);
            var exists = await checkCommand.ExecuteScalarAsync();

            if (exists == null)
            {
                // Table doesn't exist, add empty table data.
                tables.Add(tableData);
                continue;
            }

            // Get column names for the table.
            await using var columnsCommand = connection.CreateCommand();
            columnsCommand.CommandText = $"PRAGMA table_info({tableName})";
            var columns = new List<string>();
            await using (var columnsReader = await columnsCommand.ExecuteReaderAsync())
            {
                while (await columnsReader.ReadAsync())
                {
                    columns.Add(columnsReader.GetString(1));
                }
            }

            // Read all records from the table.
            await using var selectCommand = connection.CreateCommand();
            selectCommand.CommandText = $"SELECT * FROM {tableName}";
            await using var reader = await selectCommand.ExecuteReaderAsync();

            while (await reader.ReadAsync())
            {
                var record = new Dictionary<string, object?>();
                for (var i = 0; i < columns.Count; i++)
                {
                    var value = reader.GetValue(i);

                    // Convert DBNull to null for proper JSON serialization.
                    record[columns[i]] = value == DBNull.Value ? null : value;
                }

                tableData.Records.Add(record);
            }

            tables.Add(tableData);
        }

        return tables;
    }

    /// <summary>
    /// Executes the SQL statements returned by the Rust merge operation.
    /// </summary>
    /// <param name="statements">The SQL statements to execute.</param>
    /// <returns>Task.</returns>
    private async Task ExecuteMergeSqlStatementsAsync(List<SqlStatement> statements)
    {
        if (statements.Count == 0)
        {
            _logger.LogDebug("No SQL statements to execute from merge.");
            return;
        }

        _logger.LogDebug("Executing {Count} SQL statements from merge.", statements.Count);

        // Disable foreign key checks during merge execution.
        await using (var pragmaCommand = _sqlConnection!.CreateCommand())
        {
            pragmaCommand.CommandText = "PRAGMA foreign_keys = OFF;";
            await pragmaCommand.ExecuteNonQueryAsync();
        }

        try
        {
            foreach (var statement in statements)
            {
                await using var command = _sqlConnection!.CreateCommand();
                command.CommandText = statement.Sql;

                // Add parameters in order (SQLite uses positional parameters with ?).
                for (var i = 0; i < statement.Params.Count; i++)
                {
                    var value = statement.Params[i];

                    // Handle JsonElement values from deserialization.
                    if (value is JsonElement jsonElement)
                    {
                        value = ConvertJsonElementToValue(jsonElement);
                    }

                    command.Parameters.AddWithValue($"@p{i}", value ?? DBNull.Value);
                }

                // Replace ? placeholders with named parameters.
                var paramIndex = 0;
                var sql = statement.Sql;
                while (sql.Contains('?'))
                {
                    var pos = sql.IndexOf('?');
                    sql = sql[..pos] + $"@p{paramIndex}" + sql[(pos + 1)..];
                    paramIndex++;
                }

                command.CommandText = sql;
                await command.ExecuteNonQueryAsync();
            }
        }
        finally
        {
            // Re-enable foreign key checks.
            await using var pragmaCommand = _sqlConnection!.CreateCommand();
            pragmaCommand.CommandText = "PRAGMA foreign_keys = ON;";
            await pragmaCommand.ExecuteNonQueryAsync();
        }
    }

    /// <summary>
    /// Disposes the service.
    /// </summary>
    /// <param name="disposing">True if disposing.</param>
    private void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }

        if (disposing)
        {
            _sqlConnection?.Dispose();
        }

        _disposed = true;
    }
}
