using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace DodoSSH.Client.Storage;

/// <summary>
/// Opens the local cache and hands out short-lived contexts.
/// </summary>
/// <remarks>
/// <para>
/// A factory rather than one long-lived context, because a sync pass runs on a background task while
/// the interface reads the same tables, and a <see cref="DbContext"/> is not thread-safe. Each store
/// operation takes a context, does one unit of work and disposes it; SQLite serialises the writes.
/// </para>
/// <para>
/// The alternative — a single context guarded by a lock — would work and would also silently
/// accumulate a change tracker for the life of the process, which for a vault of thousands of items
/// is both a leak and a source of stale reads.
/// </para>
/// </remarks>
public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>, IDisposable
{
    private readonly DbContextOptions<ClientCacheContext> options;
    private readonly string connectionString;

    /// <remarks>
    /// An in-memory SQLite database exists only while at least one connection to it is open, so the
    /// memory-backed factory holds one for its lifetime. Null for a file-backed one.
    /// </remarks>
    private readonly SqliteConnection? keepAlive;

    private bool disposed;

    private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
    {
        this.connectionString = connectionString;
        this.keepAlive = keepAlive;

        options = new DbContextOptionsBuilder<ClientCacheContext>()
            .UseSqlite(connectionString)
            .UseSnakeCaseNamingConvention()
            .Options;
    }

    /// <summary>
    /// Opens, or creates, a cache file.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The parent directory must exist; SQLite will not create one. <c>ClientPaths.EnsureCreated</c> is
    /// what does that, and it runs before this in the application's startup.
    /// </para>
    /// <para>
    /// <b>The database ends up in WAL mode</b>, set by EF Core's SQLite provider rather than here, and
    /// that is the right mode for this design: a background sync pass writes while the interface reads,
    /// and under the default rollback journal a writer locks the whole database and those reads would fail
    /// busy. Worth knowing rather than discovering — it means the cache is three files, not one, so a
    /// backup or uninstall routine that copies or deletes only <c>cache.db</c> is wrong.
    /// </para>
    /// </remarks>
    /// <param name="databasePath">Full path to the SQLite file.</param>
    public static ClientCacheFactory ForFile(string databasePath)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);

        var builder = new SqliteConnectionStringBuilder
        {
            DataSource = databasePath,
            // Pooled, because a store operation takes a context per call and reopening the file every
            // time would be pure overhead in a process that runs for hours. The cost is that the pool
            // outlives the contexts, which Dispose has to deal with — see below.
            Pooling = true,
        };

        return new ClientCacheFactory(builder.ConnectionString, keepAlive: null);
    }

    /// <summary>
    /// Opens a private in-memory cache, for tests and for a session that must leave no trace.
    /// </summary>
    /// <param name="name">
    /// Distinguishes one in-memory database from another. Two factories given the same name share
    /// storage, which is how a test can prove that data survives a context being disposed.
    /// </param>
    public static ClientCacheFactory ForMemory(string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);

        var builder = new SqliteConnectionStringBuilder
        {
            DataSource = name,
            Mode = SqliteOpenMode.Memory,
            Cache = SqliteCacheMode.Shared,
        };

        var connection = new SqliteConnection(builder.ConnectionString);
        connection.Open();

        return new ClientCacheFactory(builder.ConnectionString, connection);
    }

    /// <inheritdoc />
    public ClientCacheContext CreateDbContext()
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return new ClientCacheContext(options);
    }

    /// <summary>
    /// Brings the schema up to date.
    /// </summary>
    /// <remarks>
    /// Called by the client at startup, before unlock — it touches no encrypted content, only the
    /// shape of the tables. It must therefore never need a key, which is also why the schema is
    /// migrated rather than recreated.
    /// </remarks>
    public async Task MigrateAsync(CancellationToken cancellationToken)
    {
        var context = CreateDbContext();
        await using var scope = context.ConfigureAwait(false);
        await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Empties the cache: every row of every table, and the pages they were written on.
    /// </summary>
    /// <remarks>
    /// <para>
    /// What signing out means on disk. The profile, the wrapped bundle, the item mirror, the outbox and
    /// the conflict log all go; the schema stays, so the application is usable again immediately and does
    /// not have to be restarted to be set up afresh.
    /// </para>
    /// <para>
    /// <b>Emptied rather than deleted, and then vacuumed.</b> Deleting the file is the obvious move and is
    /// worse here: the database is in WAL mode, so it is three files rather than one — a routine that
    /// removes <c>cache.db</c> and leaves <c>-wal</c> behind loses to a checkpoint that puts some of it
    /// back — and on Windows the pooled connections hold the file open, so the delete fails outright while
    /// the application is running. The <c>VACUUM</c> is the half that makes this a wipe rather than a
    /// hide: SQLite marks deleted pages free without overwriting them, so ciphertext and sealed records
    /// would otherwise stay legible in the file until something happened to reuse the page.
    /// </para>
    /// <para>
    /// The migrations history is deliberately left alone. It describes the shape of the tables, not the
    /// user, and clearing it would make the next launch try to apply every migration to a schema that
    /// already has them.
    /// </para>
    /// </remarks>
    public async Task ResetAsync(CancellationToken cancellationToken)
    {
        var context = CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        // Taken from the model rather than listed here, so a table added later is emptied by a sign-out
        // without anyone having to remember this method exists.
        var tables = context.Model.GetEntityTypes()
            .Select(entityType => entityType.GetTableName())
            .OfType<string>()
            .Distinct(StringComparer.Ordinal);

        foreach (var table in tables)
        {
            // A table name cannot be a parameter, so it is quoted rather than bound. The value comes from
            // this assembly's own model metadata and never from input; the doubling is what keeps that
            // true of a name somebody eventually writes with a quote in it.
            var sql = string.Concat("DELETE FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");

            // EF1002 and CA2100 both describe interpolating a value into SQL, which is what the two lines
            // above are; the value is the one thing here that cannot come from a user.
#pragma warning disable EF1002, CA2100
            await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
#pragma warning restore EF1002, CA2100
        }

        await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;
        keepAlive?.Dispose();

        // Pooled connections outlive the contexts that borrowed them, so without this the database file
        // stays open after the last context is disposed — on Windows that means locked. The application
        // then cannot delete or replace its own cache, and a test cannot clean up after itself, which is
        // how this was found. Clearing the pool is the documented way to release it.
        using var pooled = new SqliteConnection(connectionString);
        SqliteConnection.ClearPool(pooled);
    }
}

/// <summary>
/// Supplies a context to <c>dotnet ef</c>.
/// </summary>
/// <remarks>
/// Exists only for the migrations tooling, which needs to build a model without running the
/// application. The path is a throwaway: the tool reads the model, not the data.
/// </remarks>
public sealed class ClientCacheDesignTimeFactory : IDesignTimeDbContextFactory<ClientCacheContext>
{
    /// <inheritdoc />
    public ClientCacheContext CreateDbContext(string[] args) =>
        ClientCacheFactory.ForFile("dodossh-design-time.db").CreateDbContext();
}
