using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using NSec.Cryptography;

namespace DodoSSH.Client.Session;

/// <summary>A conflict, decoded and ready to show.</summary>
/// <param name="Id">The conflict record, so it can be acknowledged.</param>
/// <param name="EntityId">The item it happened to.</param>
/// <param name="Kind">What happened.</param>
/// <param name="Summary">One line for a person.</param>
/// <param name="Fields">
/// Everything the merge overrode, with the discarded values. Empty for the kinds that have no field
/// detail — a rejected push, or an item that would not decrypt.
/// </param>
/// <param name="DetectedAt">When it was noticed.</param>
public sealed record ConflictNotice(
    Guid Id,
    Guid EntityId,
    ConflictKind Kind,
    string Summary,
    IReadOnlyList<ConflictDetailEntry> Fields,
    DateTimeOffset DetectedAt);

/// <summary>One vault's outcome from a pass over all of them.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, so a message about it can name it.</param>
/// <param name="Report">What the pass did, when it completed.</param>
/// <param name="Failure">
/// Why it did not, when it failed. Carried rather than thrown so one unreachable team vault cannot
/// leave the others unsynced — and reported rather than swallowed, because a vault that silently
/// stopped syncing is the worst of the three outcomes.
/// </param>
public sealed record VaultSyncReport(
    Guid VaultId,
    string Name,
    SyncReport? Report,
    Exception? Failure)
{
    /// <summary>Whether this vault synced.</summary>
    public bool Succeeded => Report is not null;
}

/// <summary>
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
/// </summary>
/// <remarks>
/// <para>
/// Everything a session owns dies with it — the identity bundle, the vault keys and the cache key. That
/// is the whole reason this is a disposable object rather than a set of long-lived services: locking is
/// disposing, and there is exactly one place that has to be right.
/// </para>
/// <para>
/// The sync engine is <em>not</em> held here. It carries no state, so it is constructed per pass around
/// whichever transport the caller currently has — which models the actual situation, where a session is
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
/// </para>
/// </remarks>
public sealed partial class VaultSession : IAsyncDisposable
{
    private readonly UserSecretBundle bundle;
    private readonly LocalCacheProtector protector;
    private readonly VaultKeyring keyring;
    private readonly TimeProvider clock;
    private readonly SyncOptions options;

    /// <summary>
    /// Records what is done to this vault's items, for as long as this session lasts.
    /// </summary>
    /// <remarks>
    /// Owned here rather than by the shell, unlike the connection recorder beside it. An edit is finished by
    /// the time it is recorded, so nothing about it can outlive the session — where a shell genuinely can.
    /// </remarks>
    private readonly ActivityRecorder activity;

    private bool disposed;

    internal VaultSession(
        StoredUnlockMaterial profile,
        IReadOnlyList<StoredVault> vaults,
        Guid activeVaultId,
        UserSecretBundle bundle,
        LocalCacheProtector protector,
        VaultKeyring keyring,
        ClientCacheFactory caches,
        TimeProvider clock,
        SyncOptions options)
    {
        Profile = profile;
        Vaults = vaults;
        ActiveVaultId = activeVaultId;

        this.bundle = bundle;
        this.protector = protector;
        this.keyring = keyring;
        this.clock = clock;
        this.options = options;

        Items = new ItemStore(caches, protector);
        Outbox = new OutboxStore(caches, protector, clock);
        SyncState = new SyncStateStore(caches);
        Conflicts = new ConflictStore(caches, protector, clock);
        Vault = new VaultStore(caches, clock);
        Unlock = new UnlockStore(caches, clock);
        SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
        // The two log repositories first, and unaudited: the recorder writes through one of them, so a log
        // that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
        // actually stops it; building them first is what lets the recorder exist before the kinds that use
        // it. See ActivityRecorder.
        ConnectionLog = new ConnectionLogRepository(Items, Outbox, keyring);
        ActivityLog = new ActivityLogRepository(Items, Outbox, keyring);

        activity = new ActivityRecorder(
            ActivityLog, activeVaultId, profile.UserId, Environment.MachineName, clock);

        Hosts = new HostRepository(Items, Outbox, keyring, activity);
        SshKeys = new SshKeyRepository(Items, Outbox, keyring, activity);
        Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
        KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
        HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
        Tags = new TagRepository(Items, Outbox, keyring, activity);
        Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
        ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
    }

    /// <summary>Who this session belongs to, and the material that unlocked it.</summary>
    public StoredUnlockMaterial Profile { get; }

    /// <summary>Every vault this user can reach, readable or not.</summary>
    /// <remarks>
    /// Re-read rather than fixed at unlock: a vault a teammate shares arrives mid-session, and one
    /// whose grant is withdrawn stops being readable mid-session too. <see cref="RefreshVaultsAsync"/>
    /// is what moves it, and it is the only thing that does.
    /// </remarks>
    public IReadOnlyList<StoredVault> Vaults { get; private set; }

    /// <summary>
    /// The vault new items are created in.
    /// </summary>
    /// <remarks>
    /// One vault is the write target, not the read set — reading spans every vault the keyring opened.
    /// It stays the first readable one, which is the personal vault whenever there is one, because an
    /// application that silently filed a new host into a team's vault because that was the last thing
    /// selected would be the wrong default in the one direction that is hard to undo.
    /// </remarks>
    public Guid ActiveVaultId { get; }

    /// <summary>Every vault this session actually holds a key for.</summary>
    public IEnumerable<StoredVault> ReadableVaults =>
        Vaults.Where(vault => keyring.CanRead(vault.VaultId));

    /// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
    public HostRepository Hosts { get; }

    /// <summary>SSH keys, decrypted, with unpushed local changes laid over them.</summary>
    /// <remarks>
    /// Shares the item store and outbox with <see cref="Hosts"/>, so one synchronisation pass carries
    /// both and a key edit made offline queues behind a host edit in the order the user made them.
    /// </remarks>
    public SshKeyRepository SshKeys { get; }

    /// <summary>Usernames and passwords, decrypted, with unpushed local changes laid over them.</summary>
    public CredentialRepository Credentials { get; }

    /// <summary>The host keys this vault trusts, decrypted, with unpushed local changes laid over them.</summary>
    /// <remarks>
    /// Read through <see cref="VaultKnownHostStore"/> rather than directly by anything that connects. The
    /// handshake asks about host key trust from inside a synchronous SSH.NET event, and listing decrypts every
    /// pin in the vault — see that type for why the two must not meet.
    /// </remarks>
    public KnownHostRepository KnownHosts { get; }

    /// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary>
    /// <remarks>
    /// Membership is not in here. Each host carries its own <c>GroupId</c>, so a group is only ever a name,
    /// a parent and the defaults hosts under it fall back to — which is what makes filing two hosts at once
    /// on two machines two independent writes rather than one contested one.
    /// </remarks>
    public HostGroupRepository HostGroups { get; }

    /// <summary>The tags hosts wear, decrypted, with unpushed local changes laid over them.</summary>
    /// <remarks>
    /// Membership is not in here either, and for the same reason once removed: each host carries its own set
    /// of tag ids. Read beside <see cref="HostGroups"/> whenever a host list is drawn, because a tag id on a
    /// host resolves to a name only through this.
    /// </remarks>
    public TagRepository Tags { get; }

    /// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary>
    public SnippetRepository Snippets { get; }

    /// <summary>S3-compatible buckets and their credentials, decrypted.</summary>
    /// <remarks>
    /// Read when the file screen builds its picker, and the object-store client is constructed from the
    /// result. Nothing here is on a transfer's data path.
    /// </remarks>
    public ObjectStoreRepository ObjectStores { get; }

    /// <summary>The connections this vault has recorded, decrypted.</summary>
    /// <remarks>
    /// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
    /// entry is created once, on the teardown path of a session, and encrypting on that thread is how
    /// closing the application comes to take four seconds — see that type for the queue that keeps the two
    /// apart.
    /// </remarks>
    public ConnectionLogRepository ConnectionLog { get; }

    /// <summary>The keychain changes this vault has recorded, decrypted.</summary>
    public ActivityLogRepository ActivityLog { get; }

    /// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
    public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;

    internal ItemStore Items { get; }

    internal OutboxStore Outbox { get; }

    internal SyncStateStore SyncState { get; }

    internal ConflictStore Conflicts { get; }

    internal VaultStore Vault { get; }

    /// <remarks>
    /// Held so registering or forgetting a device can record it against the profile. Built here with the
    /// other stores rather than on demand, so the cache factory does not have to be kept as a field for
    /// one method's sake.
    /// </remarks>
    internal UnlockStore Unlock { get; }

    /// <remarks>
    /// Only reachable from an open session, which is the point rather than an accident of where it was
    /// put: the token is sealed under this session's cache key, so a locked machine cannot read it and
    /// therefore cannot reach the server at all. See <c>RememberedSignInStore</c>.
    /// </remarks>
    internal RememberedSignInStore SignIn { get; }

    /// <summary>
    /// Remembers the sign-in this machine currently holds, so a later launch can resume it.
    /// </summary>
    /// <param name="refreshToken">
    /// The refresh token the connection holds <em>now</em>. Providers rotate these, so a caller that
    /// notices a change has to call this again — the value is not a constant for the life of a sign-in.
    /// </param>
    /// <param name="cancellationToken">Cancellation token.</param>
    public Task RememberSignInAsync(string refreshToken, CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return SignIn.SaveAsync(refreshToken, cancellationToken);
    }

    /// <summary>
    /// Reads the sign-in this machine may resume, or null when there is none to resume.
    /// </summary>
    /// <remarks>
    /// Null covers three situations that are one situation from the caller's side — nothing was ever
    /// remembered, the record was written under a different identity, or its tag no longer verifies — and
    /// the answer to all three is the same: sign in through the browser.
    /// </remarks>
    public Task<string?> ReadRememberedSignInAsync(CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return SignIn.ReadAsync(cancellationToken);
    }

    /// <summary>Forgets the remembered sign-in.</summary>
    public Task ForgetSignInAsync(CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return SignIn.ForgetAsync(cancellationToken);
    }

    /// <summary>Runs one synchronisation pass over one vault.</summary>
    /// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
    /// <param name="vaultId">The vault to sync.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    public Task<SyncReport> SyncAsync(
        ISyncApi api,
        Guid vaultId,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);

        var engine = new SyncEngine(
            api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);

        return engine.SyncAsync(vaultId, cancellationToken);
    }

    /// <summary>
    /// Moves one vault's stored items onto its current key.
    /// </summary>
    /// <param name="api">The transport. Supplied per call, as <see cref="SyncAsync"/> takes its own.</param>
    /// <param name="vaultId">The vault to re-seal.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <remarks>
    /// What a rotation leaves to be finished. Rotating re-keys the vault and not its contents, so until
    /// this has run the items already stored are still sealed under keys a departed member may hold. It
    /// is resumable, so a pass that fails part way is re-run rather than recovered — see
    /// <see cref="VaultResealer"/>.
    /// </remarks>
    public Task<ResealReport> ResealVaultAsync(
        ISyncApi api,
        Guid vaultId,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);

        var resealer = new VaultResealer(api, Items, Outbox, keyring, clock, options);

        return resealer.ResealAsync(vaultId, cancellationToken);
    }

    /// <summary>
    /// Runs one synchronisation pass over every vault this session can read.
    /// </summary>
    /// <returns>One report per vault, in the order they were synced.</returns>
    /// <remarks>
    /// <para>
    /// Sequential rather than concurrent. Each vault has its own cursor and its own outbox, so nothing
    /// forces the order — but a client that opened one connection per vault would multiply its request
    /// rate by the number of teams somebody is in, against a server the same person is also using
    /// interactively. Vaults are few and passes are cheap.
    /// </para>
    /// <para>
    /// A vault that throws does not stop the rest. One team's vault being unreachable — a revoked grant
    /// noticed mid-pass, a server-side fault — is not a reason to leave the personal vault unsynced,
    /// and the failure is reported per vault rather than as one exception naming none of them.
    /// </para>
    /// </remarks>
    public async Task<IReadOnlyList<VaultSyncReport>> SyncAllAsync(
        ISyncApi api,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);

        var reports = new List<VaultSyncReport>();

        foreach (var vault in ReadableVaults.ToList())
        {
            try
            {
                var report = await SyncAsync(api, vault.VaultId, cancellationToken)
                    .ConfigureAwait(false);

                reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, report, null));
            }
            catch (Exception exception) when (exception is not OperationCanceledException)
            {
                reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, null, exception));
            }
        }

        return reports;
    }

    /// <summary>
    /// Registers this machine's device key, so a later launch can unlock without the passphrase.
    /// </summary>
    /// <param name="api">The transport, supplied per call as <see cref="SyncAsync"/> takes its own.</param>
    /// <param name="deviceKeys">Where the private half will live. See ADR 0007.</param>
    /// <param name="deviceName">What to call this machine in the account's device list.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>
    /// <see langword="true"/> when a device was registered; <see langword="false"/> when this machine has
    /// nowhere to keep the key, which is not a failure — it is the answer for a platform with no keystore.
    /// </returns>
    /// <remarks>
    /// <para>
    /// Here rather than in a service above, because sealing the bundle is the one step only an open session
    /// can do and this type is the bundle's custodian. Everything else — the call, the keystore — arrives as
    /// a parameter, so the session still knows nothing about how either is implemented.
    /// </para>
    /// <para>
    /// <b>Ordered so a failure cannot leave a lie behind.</b> The key is generated, stored locally, and only
    /// then registered with the server; the local wrap is cached last, once the server has accepted it. A
    /// server row whose private half was never saved is a device that can never unlock and that the account
    /// claims can, which is worse than not offering the feature — so the write that could produce it happens
    /// after the one that prevents it.
    /// </para>
    /// </remarks>
    public async Task<bool> RegisterDeviceAsync(
        IAccountApi api,
        IDeviceKeyStore deviceKeys,
        string deviceName,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);
        ArgumentNullException.ThrowIfNull(deviceKeys);
        ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);

        if (!await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(false))
        {
            return false;
        }

        using var deviceKey = Key.Create(
            KeyAgreementAlgorithm.X25519,
            new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });

        var publicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
        var wrap = bundle.SealTo(publicKey, DshAad.UserSecretBundle(Profile.UserId, Profile.KeyGeneration));

        var privateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey);

        try
        {
            await deviceKeys.SaveAsync(privateKey, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(privateKey);
        }

        // Read after the keystore write, not before, so that write stays the first thing in this method that
        // can yield. On Windows it raises a consent dialog, and a dialog wants the thread it was called from.
        var previousDeviceId = (await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false))?.DeviceId;

        var registered = await api
            .RegisterDeviceAsync(new RegisterDeviceRequest(deviceName, publicKey, wrap), cancellationToken)
            .ConfigureAwait(false);

        // A machine is a device, so registering again replaces rather than adds. The server is idempotent on
        // the public key, but this generates a fresh key pair every time and the keystore holds one — so a
        // second registration would leave the account listing a device whose private half has just been
        // overwritten. That row is not merely untidy: its kind=device wrap is the identity bundle sealed to a
        // key that no longer exists anywhere, which is precisely the leftover revocation exists to remove.
        if (previousDeviceId is { } stale && stale != registered.DeviceId)
        {
            await api.RevokeDeviceAsync(stale, cancellationToken).ConfigureAwait(false);
        }

        await Unlock.AttachDeviceAsync(registered.DeviceId, wrap, cancellationToken)
            .ConfigureAwait(false);

        return true;
    }

    /// <summary>
    /// Withdraws this machine's device key, here and on the account.
    /// </summary>
    /// <param name="api">The server, or null when there is none to reach.</param>
    /// <param name="deviceKeys">This machine's keystore.</param>
    /// <param name="cancellationToken">Cancellation.</param>
    /// <returns>How far the withdrawal got.</returns>
    /// <remarks>
    /// <para>
    /// <b>The local half first, and it is the half that matters.</b> Whether this machine may unlock without
    /// a passphrase is decided entirely by what is in the local cache and the local keystore — the unlock
    /// path never asks the server — so forgetting here is what actually revokes. Doing it first also means a
    /// server call that fails cannot leave the machine still able to let itself in.
    /// </para>
    /// <para>
    /// The server row is not bookkeeping, though, which is why this no longer stops at the local half. A
    /// <c>kind=device</c> wrap is the user's identity bundle sealed to a key that may be in somebody else's
    /// laptop; leaving it there means a machine that is wiped and reinstalled can pull the wrap down again,
    /// and it means the account goes on listing a device nobody can account for.
    /// </para>
    /// <para>
    /// Offline still does the local half and says so, rather than refusing. Somebody revoking a device
    /// usually has a reason to want it gone <em>now</em>, and "you are offline, so this machine will go on
    /// unlocking itself" is the worst of the available answers. <see cref="DeviceRevocation.LocalOnly"/> is
    /// what the interface reports, and it is a state the user can act on by trying again online.
    /// </para>
    /// <para>
    /// The device id is read from the store rather than from <see cref="Profile"/>, which is a snapshot taken
    /// when the session opened and does not know about a device registered since.
    /// </para>
    /// </remarks>
    public async Task<DeviceRevocation> ForgetDeviceAsync(
        IAccountApi? api,
        IDeviceKeyStore deviceKeys,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(deviceKeys);

        // Before any await that could yield, because on Windows this reaches a consent dialog and a dialog
        // needs the thread it was called from to be one that pumps messages. See the desktop head's
        // WindowsDeviceKeyStore — this layer only knows it is handed an IDeviceKeyStore.
        await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false);

        var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false);

        await Unlock.DetachDeviceAsync(cancellationToken).ConfigureAwait(false);

        if (stored?.DeviceId is not { } deviceId)
        {
            return DeviceRevocation.NothingRegistered;
        }

        if (api is null)
        {
            return DeviceRevocation.LocalOnly;
        }

        // A device the account does not have is the state this was aiming at, so a false answer is an
        // arrival rather than a failure — another machine may have revoked it first.
        await api.RevokeDeviceAsync(deviceId, cancellationToken).ConfigureAwait(false);

        return DeviceRevocation.Complete;
    }

    /// <summary>
    /// Reads the conflicts a person still needs to see.
    /// </summary>
    /// <remarks>
    /// A conflict whose detail will not decode is still reported, with the reason in place of the
    /// summary. The record itself — which item, when, what kind — remains useful even when the
    /// discarded value has become unreadable, and dropping the row would be the one outcome the whole
    /// conflict log exists to avoid.
    /// </remarks>
    public async Task<IReadOnlyList<ConflictNotice>> ReadConflictsAsync(
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        var stored = await Conflicts
            .ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
            .ConfigureAwait(false);

        return [.. stored.Select(Describe)];
    }

    /// <summary>Marks a conflict as seen, keeping the discarded value retrievable.</summary>
    public Task<bool> AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
    }

    /// <summary>
    /// How many local changes the <em>user</em> has made that are waiting to be pushed.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Log entries are excluded, and the exclusion is the honest reading rather than a convenience.</b>
    /// This number is shown in the titlebar and it answers one question: how much of my work is not yet
    /// safe anywhere else. A connection that was recorded is not somebody's work — nobody typed it, nobody
    /// would re-enter it if this machine were lost, and an entry queued a moment after a save would leave
    /// the titlebar claiming an unsynced change immediately after reporting a successful sync.
    /// </para>
    /// <para>
    /// The entries are still pushed, on the next pass like anything else. What they are kept out of is a
    /// count that means something narrower than "rows in the outbox".
    /// </para>
    /// </remarks>
    public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);

        return pending.Count(operation => operation.EntityType is not (
            SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry));
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;

        // Before the keys go, and it waits — briefly. Anything queued has to be encrypted under a vault key
        // that is about to be zeroed, so a fire-and-forget here would silently lose the last few entries of
        // every session. The wait is bounded inside the recorder; locking never stalls on it.
        await activity.DisposeAsync().ConfigureAwait(false);

        // Order is not important — none of these depend on another — but completeness is. Missing one
        // leaves key material in memory for the life of the process, which is the opposite of what
        // locking is supposed to mean.
        keyring.Dispose();
        protector.Dispose();
        bundle.Dispose();
    }

    private static ConflictNotice Describe(StoredConflict conflict)
    {
        var detail = ConflictDetails.TryRead(conflict.Detail);

        return new ConflictNotice(
            conflict.Id,
            conflict.EntityId,
            conflict.Kind,
            detail?.Summary ?? "The details of this conflict could not be read.",
            detail?.Fields ?? [],
            conflict.DetectedAt);
    }
}
