using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace DodoSSH.Client.Storage;

/// <summary>
/// Stores a timestamp as Unix milliseconds.
/// </summary>
/// <remarks>
/// <para>
/// Not a preference. SQLite has no date type, and EF's default mapping for
/// <see cref="DateTimeOffset"/> is a text form that it then <b>refuses to order or compare</b> — any
/// query with <c>ORDER BY</c> or a range filter on such a column throws
/// <see cref="NotSupportedException"/> at execution time, not at model build. Collecting tombstones
/// older than a cutoff and listing conflicts newest-first are both exactly that shape, so this was a
/// crash waiting for the first user with a deleted host. Found by the tests that do both.
/// </para>
/// <para>
/// An integer also sorts and compares correctly by construction, which the text form does not once
/// two rows carry different UTC offsets. The cost is losing sub-millisecond precision and normalising
/// to UTC — neither of which matters here, and both of which docs/crypto.md §7 already does to every
/// timestamp it signs over.
/// </para>
/// </remarks>
internal sealed class UnixMillisecondsConverter : ValueConverter<DateTimeOffset, long>
{
    /// <remarks>Public because EF instantiates this reflectively and needs a public constructor.</remarks>
    public UnixMillisecondsConverter()
        : base(
            value => value.ToUnixTimeMilliseconds(),
            value => DateTimeOffset.FromUnixTimeMilliseconds(value))
    {
    }
}

/// <summary>
/// The local cache database.
/// </summary>
/// <remarks>
/// <para>
/// Public only because the migrations tooling needs to reach it. The row types stay internal and
/// there are no <see cref="DbSet{TEntity}"/> properties: callers go through the stores, which is what
/// keeps the sealing of protected columns from being something a call site can forget. Entities are
/// registered explicitly in <see cref="OnModelCreating"/> and reached with
/// <see cref="DbContext.Set{TEntity}()"/>.
/// </para>
/// <para>
/// Migrations rather than <c>EnsureCreated</c>, even for a cache. The item rows are indeed disposable
/// — worst case they re-pull from a null cursor — but <see cref="UnlockMaterialRow"/> is not: dropping
/// it would mean a user who upgrades while offline cannot open their vault until they are back on the
/// network, which is exactly the situation the offline unlock exists for.
/// </para>
/// </remarks>
public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> options)
    : DbContext(options)
{
    /// <inheritdoc />
    /// <remarks>
    /// Applied as a convention rather than per property, so a timestamp added later cannot be the one
    /// that is left un-converted — which would fail only when something eventually sorted by it.
    /// </remarks>
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        ArgumentNullException.ThrowIfNull(configurationBuilder);

        configurationBuilder.Properties<DateTimeOffset>().HaveConversion<UnixMillisecondsConverter>();
    }

    /// <inheritdoc />
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ArgumentNullException.ThrowIfNull(modelBuilder);

        ConfigureUnlockMaterial(modelBuilder);
        ConfigureRememberedSignIn(modelBuilder);
        ConfigureVaults(modelBuilder);
        ConfigureItems(modelBuilder);
        ConfigureOutbox(modelBuilder);
        ConfigureSyncState(modelBuilder);
        ConfigureConflicts(modelBuilder);
    }

    private static void ConfigureUnlockMaterial(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<UnlockMaterialRow>(entity =>
        {
            entity.ToTable(
                "unlock_material",
                // One server and one user per cache file. The constraint is here rather than only in
                // code so that a second row cannot appear through any path at all — including a
                // future migration written by someone who has not read this comment.
                table => table.HasCheckConstraint(
                    "ck_unlock_material_singleton",
                    $"id = {UnlockMaterialRow.SingletonId}"));

            entity.HasKey(row => row.Id);
            entity.Property(row => row.Id).ValueGeneratedNever();
            entity.Property(row => row.ServerUrl).IsRequired();
            entity.Property(row => row.Issuer).IsRequired();
            entity.Property(row => row.Subject).IsRequired();
            entity.Property(row => row.WrappedPrivateKey).IsRequired();
            entity.Property(row => row.KdfAlgorithm).IsRequired();
            entity.Property(row => row.KdfSalt).IsRequired();
        });

    /// <remarks>
    /// A table of its own rather than two more columns on <c>unlock_material</c>, because the two rows have
    /// opposite lifetimes: the unlock material is what makes this machine work offline and must survive
    /// everything short of a reset, while a remembered sign-in is dropped the moment the server stops
    /// accepting it. Deleting one must never be able to take the other with it.
    /// </remarks>
    private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<RememberedSignInRow>(entity =>
        {
            entity.ToTable(
                "remembered_sign_in",
                table => table.HasCheckConstraint(
                    "ck_remembered_sign_in_singleton",
                    $"id = {RememberedSignInRow.SingletonId}"));

            entity.HasKey(row => row.Id);
            entity.Property(row => row.Id).ValueGeneratedNever();
            entity.Property(row => row.SealedRefreshToken).IsRequired();
        });

    private static void ConfigureVaults(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<CachedVaultRow>(entity =>
        {
            entity.ToTable("vault");
            entity.HasKey(row => row.VaultId);
            entity.Property(row => row.VaultId).ValueGeneratedNever();
            entity.Property(row => row.Name).IsRequired();
        });

        // No foreign key to the vault row, deliberately. The two are written by the same store in the
        // same call, and a cascade would make "which of these two tables is authoritative" a question
        // the schema answers rather than the code — while buying nothing, since a wrap for a vault this
        // machine can no longer see is removed by the same pass that removes the vault.
        modelBuilder.Entity<CachedVaultKeyWrapRow>(entity =>
        {
            entity.ToTable("vault_key_wrap");
            entity.HasKey(row => new { row.VaultId, row.KeyGeneration });
            entity.Property(row => row.WrappedKey).IsRequired();
        });
    }

    private static void ConfigureItems(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<CachedItemRow>(entity =>
        {
            entity.ToTable("item");

            // Composite rather than the entity id alone. Ids are UUIDv7 and globally unique in
            // practice, but making the vault part of the identity means a row can never be read out
            // of the wrong vault by a query that forgot to filter.
            entity.HasKey(row => new { row.VaultId, row.EntityType, row.EntityId });

            entity.HasIndex(row => new { row.VaultId, row.EntityType });
            entity.HasIndex(row => new { row.VaultId, row.ChangeSequence });
        });

    private static void ConfigureOutbox(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<OutboxRow>(entity =>
        {
            entity.ToTable("outbox");
            entity.HasKey(row => row.Sequence);
            entity.Property(row => row.Sequence).ValueGeneratedOnAdd();

            // At most one pending operation per item, enforced by the database rather than by
            // convention. Two queued edits to one item would have to be pushed in order, and the
            // second would need the version the first produced — which is not known when it is
            // queued. Coalescing into this single row avoids the problem instead of managing it.
            entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId }).IsUnique();

            // The drain order.
            entity.HasIndex(row => new { row.VaultId, row.IsParked, row.Sequence });

            entity.HasIndex(row => row.OperationId).IsUnique();
        });

    private static void ConfigureSyncState(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<SyncStateRow>(entity =>
        {
            entity.ToTable("sync_state");
            entity.HasKey(row => row.VaultId);
            entity.Property(row => row.VaultId).ValueGeneratedNever();
        });

    private static void ConfigureConflicts(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<ConflictRow>(entity =>
        {
            entity.ToTable("conflict");
            entity.HasKey(row => row.Id);
            entity.Property(row => row.Id).ValueGeneratedNever();
            entity.Property(row => row.Detail).IsRequired();
            entity.HasIndex(row => new { row.VaultId, row.Acknowledged });
            entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId });
        });
}
