using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// One machine: its own cache, its own outbox, its own view of the vault.
/// </summary>
/// <remarks>
/// A separate SQLite database per device, because the whole subject of these tests is two caches
/// diverging and being reconciled. Sharing one would make every conflict test vacuous.
/// </remarks>
internal sealed class SyncDevice : IDisposable
{
    private readonly ClientCacheFactory factory;
    private readonly LocalCacheProtector protector;

    private readonly FakeVaultServer server;
    private readonly SyncOptions options;

    private SyncDevice(
        string name,
        ClientCacheFactory factory,
        LocalCacheProtector protector,
        VaultKeyring keyring,
        FakeVaultServer server,
        SyncOptions options)
    {
        Name = name;
        this.factory = factory;
        this.protector = protector;
        this.server = server;
        this.options = options;
        Keyring = keyring;

        Items = new ItemStore(factory, protector);
        Outbox = new OutboxStore(factory, protector, TimeProvider.System);
        SyncState = new SyncStateStore(factory);
        Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
        Hosts = new HostRepository(Items, Outbox, keyring);
        SshKeys = new SshKeyRepository(Items, Outbox, keyring);
        Credentials = new CredentialRepository(Items, Outbox, keyring);
        KnownHosts = new KnownHostRepository(Items, Outbox, keyring);

        Engine = new SyncEngine(
            server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options);
    }

    internal string Name { get; }

    internal VaultKeyring Keyring { get; }

    internal ItemStore Items { get; }

    internal OutboxStore Outbox { get; }

    internal SyncStateStore SyncState { get; }

    internal ConflictStore Conflicts { get; }

    internal HostRepository Hosts { get; }

    internal SshKeyRepository SshKeys { get; }

    internal CredentialRepository Credentials { get; }

    internal KnownHostRepository KnownHosts { get; }

    internal SyncEngine Engine { get; }

    internal static async Task<SyncDevice> CreateAsync(
        string name,
        UserSecretBundle bundle,
        StoredVault vault,
        FakeVaultServer server,
        SyncOptions options)
    {
        var cache = ClientCacheFactory.ForMemory($"sync-{name}-{Guid.CreateVersion7():N}");

        try
        {
            await cache.MigrateAsync(TestContext.Current.CancellationToken);

            // Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
            var keyring = VaultKeyring.Open(bundle, [vault]);

            // Both simulated machines derive the same cache key, because they are the same user holding the
            // same identity — which is what keying the cache on the bundle means. They still have separate
            // cache databases, so nothing is shared between them but the key that would open either.
            return new SyncDevice(
                name, cache, LocalCacheProtector.From(bundle), keyring, server, options);
        }
        catch
        {
            cache.Dispose();
            throw;
        }
    }

    internal Task<SyncReport> SyncAsync() =>
        Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);

    /// <summary>Moves everything this machine can see onto the vault's current key.</summary>
    /// <remarks>
    /// Built per call rather than held, as the engine is: it carries no state between passes, and one
    /// per call is what the session does.
    /// </remarks>
    internal Task<ResealReport> ResealAsync() =>
        new VaultResealer(server, Items, Outbox, Keyring, TimeProvider.System, options)
            .ResealAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);

    internal Task<ItemListing<HostSecret>> ListAsync() =>
        Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);

    internal async Task<IReadOnlyList<HostSecret>> HostsSortedAsync()
    {
        var listing = await ListAsync();

        return [.. listing.Items.Select(h => h.Secret).OrderBy(h => h.Label, StringComparer.Ordinal)];
    }

    internal async Task<VaultItem<HostSecret>> FindAsync(Guid entityId)
    {
        var listing = await ListAsync();

        return listing.Items.SingleOrDefault(host => host.EntityId == entityId)
            ?? throw new InvalidOperationException($"{Name} cannot see host {entityId}.");
    }

    internal Task<Guid> CreateAsync(HostSecret host) =>
        Hosts.CreateAsync(SyncHarness.VaultId, host, TestContext.Current.CancellationToken);

    internal Task UpdateAsync(Guid entityId, HostSecret host) =>
        Hosts.UpdateAsync(SyncHarness.VaultId, entityId, host, TestContext.Current.CancellationToken);

    internal Task DeleteAsync(Guid entityId) =>
        Hosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);

    // ---- The same four operations, on SSH keys ----

    internal Task<ItemListing<SshKeySecret>> ListKeysAsync() =>
        SshKeys.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);

    internal async Task<VaultItem<SshKeySecret>> FindKeyAsync(Guid entityId)
    {
        var listing = await ListKeysAsync();

        return listing.Items.SingleOrDefault(key => key.EntityId == entityId)
            ?? throw new InvalidOperationException($"{Name} cannot see key {entityId}.");
    }

    internal Task<Guid> CreateKeyAsync(SshKeySecret key) =>
        SshKeys.CreateAsync(SyncHarness.VaultId, key, TestContext.Current.CancellationToken);

    internal Task UpdateKeyAsync(Guid entityId, SshKeySecret key) =>
        SshKeys.UpdateAsync(SyncHarness.VaultId, entityId, key, TestContext.Current.CancellationToken);

    internal Task DeleteKeyAsync(Guid entityId) =>
        SshKeys.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);

    // ---- And again on credentials ----

    internal Task<ItemListing<CredentialSecret>> ListCredentialsAsync() =>
        Credentials.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);

    internal async Task<VaultItem<CredentialSecret>> FindCredentialAsync(Guid entityId)
    {
        var listing = await ListCredentialsAsync();

        return listing.Items.SingleOrDefault(credential => credential.EntityId == entityId)
            ?? throw new InvalidOperationException($"{Name} cannot see credential {entityId}.");
    }

    internal Task<Guid> CreateCredentialAsync(CredentialSecret credential) =>
        Credentials.CreateAsync(SyncHarness.VaultId, credential, TestContext.Current.CancellationToken);

    internal Task UpdateCredentialAsync(Guid entityId, CredentialSecret credential) =>
        Credentials.UpdateAsync(
            SyncHarness.VaultId, entityId, credential, TestContext.Current.CancellationToken);

    // ---- And again on known host keys ----

    internal Task<ItemListing<KnownHostSecret>> ListKnownHostsAsync() =>
        KnownHosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);

    internal async Task<VaultItem<KnownHostSecret>> FindKnownHostAsync(Guid entityId)
    {
        var listing = await ListKnownHostsAsync();

        return listing.Items.SingleOrDefault(pin => pin.EntityId == entityId)
            ?? throw new InvalidOperationException($"{Name} cannot see known host key {entityId}.");
    }

    internal Task<Guid> CreateKnownHostAsync(KnownHostSecret knownHost) =>
        KnownHosts.CreateAsync(SyncHarness.VaultId, knownHost, TestContext.Current.CancellationToken);

    internal Task UpdateKnownHostAsync(Guid entityId, KnownHostSecret knownHost) =>
        KnownHosts.UpdateAsync(
            SyncHarness.VaultId, entityId, knownHost, TestContext.Current.CancellationToken);

    internal Task DeleteKnownHostAsync(Guid entityId) =>
        KnownHosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);

    internal Task<IReadOnlyList<StoredConflict>> ConflictsAsync() =>
        Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);

    /// <inheritdoc />
    public void Dispose()
    {
        Keyring.Dispose();
        protector.Dispose();
        factory.Dispose();
    }
}

/// <summary>
/// One user, one vault, two machines and a server.
/// </summary>
/// <remarks>
/// Both devices share the identity bundle, which is what a single user on a laptop and a desktop
/// actually looks like: one enrolled key pair, one vault grant, two independent local caches. That is
/// also the cheapest realistic setup in which every conflict case can be produced.
/// </remarks>
internal sealed class SyncHarness : IDisposable
{
    internal static readonly Argon2Profile CheapProfile =
        Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);

    private readonly UserSecretBundle bundle;

    private SyncHarness(UserSecretBundle bundle, FakeVaultServer server, SyncDevice first, SyncDevice second)
    {
        this.bundle = bundle;
        Server = server;
        First = first;
        Second = second;
    }

    internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e0f");

    internal FakeVaultServer Server { get; }

    /// <summary>The laptop.</summary>
    internal SyncDevice First { get; }

    /// <summary>The desktop.</summary>
    internal SyncDevice Second { get; }

    internal static async Task<SyncHarness> CreateAsync(SyncOptions? options = null)
    {
        var effective = options ?? SyncOptions.Default;

        var identity = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));

        try
        {
            var vaultKey = VaultKeys.Create();
            var wrapped = VaultKeys.WrapTo(vaultKey, identity.EncryptionPublicKey, VaultId, 1);

            // The plaintext key is not retained: each device unwraps the grant itself, as it would after
            // an ordinary unlock.
            System.Security.Cryptography.CryptographicOperations.ZeroMemory(vaultKey);

            var vault = new StoredVault(
                VaultId, "Personal", IsPersonal: true, TeamId: null, KeyGeneration: 1,
                Permissions: 31, wrapped, RekeyRequired: false);

            var server = new FakeVaultServer(VaultId);

            var first = await SyncDevice.CreateAsync("laptop", identity, vault, server, effective);

            try
            {
                var second = await SyncDevice.CreateAsync("desktop", identity, vault, server, effective);
                return new SyncHarness(identity, server, first, second);
            }
            catch
            {
                first.Dispose();
                throw;
            }
        }
        catch
        {
            identity.Dispose();
            throw;
        }
    }

    /// <summary>
    /// Rotates the vault: a new key, taken by both machines, and a server that says so.
    /// </summary>
    /// <returns>The key the vault has just moved off, so a test can prove it no longer opens anything.</returns>
    /// <remarks>
    /// <para>
    /// Stands in for the server call the real rotation makes. What matters here is the state it leaves —
    /// a vault whose current generation is one past everything stored in it — and the grant round trip
    /// that produces that state is <c>DodoSSH.Api.Tests</c>'s subject, not this suite's.
    /// </para>
    /// <para>
    /// Each keyring gets its own copy of the bytes, because a keyring owns what it is handed and zeroes
    /// it on disposal; sharing one array would leave the second machine holding a zeroed key at the end
    /// of a test and produce failures that look like a decryption bug.
    /// </para>
    /// </remarks>
    internal byte[] Rotate()
    {
        var generation = Server.KeyGeneration + 1;

        First.Keyring.TryGetAt(VaultId, Server.KeyGeneration, out var previous).ShouldBeTrue();

        var superseded = previous.ToArray();
        var key = VaultKeys.Create();

        First.Keyring.Adopt(VaultId, [.. key], generation);
        Second.Keyring.Adopt(VaultId, [.. key], generation);

        CryptographicOperations.ZeroMemory(key);

        Server.KeyGeneration = generation;

        return superseded;
    }

    /// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary>
    /// <remarks>
    /// Twice because one pass per device is not enough for a change made on one to be merged on the
    /// other and then pushed back. Asserting on a settled state rather than on an intermediate one is
    /// what makes "the two devices converge" a meaningful claim.
    /// </remarks>
    internal async Task SettleAsync()
    {
        for (var round = 0; round < 2; round++)
        {
            await First.SyncAsync();
            await Second.SyncAsync();
        }
    }

    /// <inheritdoc />
    public void Dispose()
    {
        First.Dispose();
        Second.Dispose();
        bundle.Dispose();
    }

    // ---- Builders ----

    internal static HostSecret Host(
        string label,
        string hostname = "db.internal",
        int port = 22,
        string? username = "deploy",
        string? notes = null,
        (string Name, string Value)[]? options = null,
        bool relayEnabled = false) =>
        new()
        {
            Label = label,
            Hostname = hostname,
            Port = port,
            Username = username,
            Notes = notes,
            Options = options is null
                ? HostOptions.Empty
                : HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
            RelayEnabled = relayEnabled,
        };

    /// <summary>
    /// An SSH key whose material is a plausible shape but not a real key.
    /// </summary>
    /// <remarks>
    /// Not a valid Ed25519 key, and deliberately so: nothing in the sync path parses the material, and a
    /// real private key checked into a test repository is a real private key on the internet regardless of
    /// what it was used for. <c>SshKeySecret.TryValidate</c> only requires the armour, and the tests that
    /// need a key SSH.NET can actually load live in <c>DodoSSH.Client.Ssh.Tests</c> where one is generated.
    /// </remarks>
    /// <summary>A credential for the suites, varying only what a test is about.</summary>
    internal static CredentialSecret Credential(
        string label,
        string password = "hunter2",
        string? username = null,
        string? notes = null) =>
        new() { Label = label, Password = password, Username = username, Notes = notes };

    /// <summary>A pinned host key, varying only what a test is about.</summary>
    /// <remarks>
    /// The fingerprint is a plausible shape rather than a real digest. Nothing in the sync path hashes
    /// anything or checks the encoding — <c>SshHostKeyFingerprint</c> does that, one layer down and in its own
    /// suite — so a value that reads as one is worth more here than a genuine one.
    /// </remarks>
    internal static KnownHostSecret KnownHost(
        string host = "db.internal",
        int port = 22,
        string algorithm = "ssh-ed25519",
        string fingerprint = "SHA256:AAAAtestfingerprint0123456789abcdefghijklmno") =>
        new()
        {
            Host = host,
            Port = port,
            Algorithm = algorithm,
            Fingerprint = fingerprint,
        };

    internal static SshKeySecret Key(
        string label,
        string material = "deploy-key-material",
        string? passphrase = null,
        string? publicKey = null,
        string? notes = null) =>
        new()
        {
            Label = label,
            PrivateKeyPem = $"-----BEGIN OPENSSH PRIVATE KEY-----\n{material}\n"
                + "-----END OPENSSH PRIVATE KEY-----\n",
            Passphrase = passphrase,
            PublicKey = publicKey,
            Notes = notes,
        };
}
