using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.FieldChange;

namespace DodoSSH.Client.Sync;

/// <summary>A decrypted item, and whether this build may write it back.</summary>
/// <param name="Secret">The item.</param>
/// <param name="IsReadOnly">
/// Whether a newer client wrote it, in which case re-encoding it here would drop fields this build has
/// no concept of.
/// </param>
internal sealed record OpenedItem<TSecret>(TSecret Secret, bool IsReadOnly)
    where TSecret : class, IVaultSecret;

/// <summary>A merged item, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The item to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
internal sealed record MergedItem<TSecret>(
    TSecret Merged,
    IReadOnlyList<HostFieldConflict> Conflicts)
    where TSecret : class, IVaultSecret;

/// <summary>
/// Everything about one item type that the shared sync path cannot know.
/// </summary>
/// <remarks>
/// <para>
/// The reconciler holds the six answers a collision can have — merge, adopt, resurrect, abandon, park,
/// refuse — and every one of them is identical for a host, an SSH key, a credential and a pinned host key.
/// Only the encoding,
/// the merge and the plaintext columns differ, and those arrive through here. A second copy of the
/// reconciler per item type is the alternative, and it is not a real one: the file's whole premise is
/// that the pull and push paths must answer the same situation the same way, and two copies would drift
/// the moment one of them was fixed.
/// </para>
/// <para>
/// Generic, unlike the server's <c>IItemKind</c>, and for a reason that reverses there: the client
/// <em>does</em> need the concrete type. It merges two versions of an item field by field and hands the
/// result to a codec, so erasing the type would only move the downcasts inside the reconciler, where
/// they would be a cast per branch instead of none.
/// </para>
/// </remarks>
internal interface IItemKind<TSecret>
    where TSecret : class, IVaultSecret
{
    /// <summary>The type as the wire contract names it.</summary>
    SyncEntityType EntityType { get; }

    /// <summary>
    /// What to call one of these when telling a person what happened to it.
    /// </summary>
    /// <remarks>
    /// Lower case and singular, because every use is mid-sentence. This exists because the conflict log
    /// is read by people: "this host could not be decrypted" is actively misleading when the item was a
    /// private key, and a user who is told the wrong noun looks in the wrong place.
    /// </remarks>
    string Noun { get; }

    /// <inheritdoc cref="HostCipher.TryOpen" />
    OpenedItem<TSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion);

    /// <inheritdoc cref="HostCipher.Seal" />
    EncryptedPayload Seal(
        TSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion);

    /// <summary>
    /// The plaintext columns the server gets, or null when this type gives it nothing.
    /// </summary>
    /// <remarks>
    /// Nullable rather than an all-defaults record, because the difference is visible on the wire and to
    /// a reader: <c>SyncPlaintextFields</c> with nothing set still serialises <c>relayEnabled: false</c>,
    /// which invites the belief that the type has a relay setting which happens to be off.
    /// </remarks>
    SyncPlaintextFields? Fields(TSecret secret);

    /// <summary>Merges two divergent versions against the version they both started from.</summary>
    MergedItem<TSecret> Merge(TSecret ancestor, TSecret local, TSecret remote);

    /// <summary>
    /// The names of the fields that differ between two versions of an item.
    /// </summary>
    /// <remarks>
    /// <para>
    /// For the activity log, which records what changed and never what it changed to. Written per kind
    /// rather than derived by reflection, for two reasons: this project's serialisation is source-generated
    /// precisely to keep reflection out of it, and the names here are read by a person — so each kind gets
    /// to say "Passphrase" rather than whatever a property happens to be called.
    /// </para>
    /// <para>
    /// <b>Never a value.</b> A log that recorded an old password would be a plaintext credential store with
    /// a vault drawn around it; see ADR 0006, which imposes the same rule on the server's own detail column.
    /// </para>
    /// </remarks>
    IReadOnlyList<string> Changes(TSecret before, TSecret after);

    /// <summary>
    /// Whether writing one of these is worth a line in the activity log.
    /// </summary>
    /// <remarks>
    /// False for the log kinds themselves, and that is not a preference: the activity hook sits in the one
    /// generic repository every kind goes through, so a log entry that logged itself would produce an entry
    /// per entry, for ever. It is stated per kind rather than special-cased at the call site so that a
    /// future kind with the same shape — anything written by the machine rather than by a person — cannot
    /// re-enter the loop by being forgotten.
    /// </remarks>
    bool IsAudited => true;

    /// <summary>The same item under a new name, for a resurrection.</summary>
    TSecret Relabel(TSecret secret, string label);
}

/// <summary>
/// The item types this client synchronises.
/// </summary>
/// <remarks>
/// <para>
/// <b>One list, and the pull filter is derived from it.</b> The engine asks the server for exactly the
/// types in <see cref="Registry"/> and refuses to apply a change of any other type, so adding a kind
/// cannot leave the filter behind — which is the specific way this would otherwise break: an item type
/// that reads and writes perfectly in every unit test and is never once requested from the server.
/// </para>
/// <para>
/// A reconciler per type rather than one shared instance, because each closes the generic over its own
/// secret type. They are cheap — four fields and no state — and building them once per engine keeps the
/// per-change path a dictionary lookup.
/// </para>
/// </remarks>
internal static class ItemKinds
{
    private static readonly (SyncEntityType Type, ReconcilerFactory Create)[] Registry =
    [
        (SyncEntityType.Host, static (outbox, conflicts, keyring) =>
            new ItemReconciler<HostSecret>(HostKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.SshKey, static (outbox, conflicts, keyring) =>
            new ItemReconciler<SshKeySecret>(SshKeyKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.Credential, static (outbox, conflicts, keyring) =>
            new ItemReconciler<CredentialSecret>(CredentialKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) =>
            new ItemReconciler<KnownHostSecret>(KnownHostKeyKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
            new ItemReconciler<HostGroupSecret>(HostGroupKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.Tag, static (outbox, conflicts, keyring) =>
            new ItemReconciler<TagSecret>(TagKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
            new ItemReconciler<SnippetSecret>(SnippetKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.ConnectionLogEntry, static (outbox, conflicts, keyring) =>
            new ItemReconciler<ConnectionLogSecret>(
                ConnectionLogEntryKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.ActivityLogEntry, static (outbox, conflicts, keyring) =>
            new ItemReconciler<ActivityLogSecret>(
                ActivityLogEntryKind.Instance, outbox, conflicts, keyring)),

        (SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
            new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
    ];

    /// <summary>The types to ask the server for, in a fixed order.</summary>
    internal static IReadOnlyList<SyncEntityType> SyncedTypes { get; } =
        [.. Registry.Select(entry => entry.Type)];

    private delegate IItemReconciler ReconcilerFactory(
        OutboxStore outbox,
        ConflictStore conflicts,
        VaultKeyring keyring);

    /// <summary>Builds one reconciler per synchronised type.</summary>
    internal static Dictionary<SyncEntityType, IItemReconciler> Reconcilers(
        OutboxStore outbox,
        ConflictStore conflicts,
        VaultKeyring keyring) =>
        Registry.ToDictionary(
            entry => entry.Type,
            entry => entry.Create(outbox, conflicts, keyring));
}

/// <summary>What the per-kind field comparisons share.</summary>
/// <remarks>
/// One line per field at each call site, which is the point: the alternative was reflection, and this
/// project keeps reflection out of its serialisation on purpose. Comparison is <see cref="object.Equals(object)"/>
/// on the values, which is why the collection-shaped fields on a host are types with structural equality —
/// <c>JumpChain</c> and <c>HostOptions</c> — rather than plain lists.
/// </remarks>
internal static class FieldChange
{
    internal static void Note<T>(List<string> changed, string name, T before, T after)
    {
        if (!EqualityComparer<T>.Default.Equals(before, after))
        {
            changed.Add(name);
        }
    }
}

/// <summary>Hosts.</summary>
internal sealed class HostKind : IItemKind<HostSecret>
{
    internal static HostKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.Host;

    /// <inheritdoc />
    public string Noun => "host";

    /// <inheritdoc />
    public OpenedItem<HostSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = HostCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null ? null : new OpenedItem<HostSecret>(document.Host, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        HostSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        HostCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <inheritdoc />
    public SyncPlaintextFields? Fields(HostSecret secret) => HostFields.From(secret);

    /// <inheritdoc />
    public MergedItem<HostSecret> Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
    {
        var merged = HostSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<HostSecret>(merged.Merged, merged.Conflicts);
    }

    /// <inheritdoc />
    public IReadOnlyList<string> Changes(HostSecret before, HostSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);
        Note(changed, "Hostname", before.Hostname, after.Hostname);
        Note(changed, "Port", before.Port, after.Port);
        Note(changed, "Username", before.Username, after.Username);
        Note(changed, "Notes", before.Notes, after.Notes);
        Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
        Note(changed, "Options", before.Options, after.Options);
        Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
        Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
        Note(changed, "Credential", before.CredentialId, after.CredentialId);
        Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
        Note(changed, "Group", before.GroupId, after.GroupId);
        Note(changed, "Tags", before.TagIds, after.TagIds);

        return changed;
    }

    /// <inheritdoc />
    public HostSecret Relabel(HostSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}

/// <summary>SSH keys.</summary>
internal sealed class SshKeyKind : IItemKind<SshKeySecret>
{
    internal static SshKeyKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.SshKey;

    /// <inheritdoc />
    public string Noun => "SSH key";

    /// <inheritdoc />
    public OpenedItem<SshKeySecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null ? null : new OpenedItem<SshKeySecret>(document.Key, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        SshKeySecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        SshKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing at all.
    /// </summary>
    /// <remarks>
    /// The server has a <c>public_key_fingerprint</c> column and would accept one here, and this client
    /// deliberately declines to fill it. A fingerprint is not a secret, but it is a stable identifier for
    /// a key pair, and handing it over would let the operator tell which of their users hold the same key
    /// and correlate one key across vaults — for a column nothing in the product reads. The design allows
    /// itself exactly one plaintext concession, the relay address, and it is a concession because the
    /// relay cannot work without it. This is not that. See ADR 0004.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(SshKeySecret secret) => null;

    /// <inheritdoc />
    public MergedItem<SshKeySecret> Merge(SshKeySecret ancestor, SshKeySecret local, SshKeySecret remote)
    {
        var merged = SshKeySecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<SshKeySecret>(merged.Merged, merged.Conflicts);
    }

    /// <remarks>
    /// The private key is compared and never reported by value, which is the whole rule. "Private key"
    /// appearing in a log is the fact somebody needs; the key itself is what nobody does.
    /// </remarks>
    /// <inheritdoc />
    public IReadOnlyList<string> Changes(SshKeySecret before, SshKeySecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);
        Note(changed, "Private key", before.PrivateKeyPem, after.PrivateKeyPem);
        Note(changed, "Passphrase", before.Passphrase, after.Passphrase);
        Note(changed, "Public key", before.PublicKey, after.PublicKey);
        Note(changed, "Notes", before.Notes, after.Notes);

        return changed;
    }

    /// <inheritdoc />
    public SshKeySecret Relabel(SshKeySecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}

/// <summary>Credentials.</summary>
internal sealed class CredentialKind : IItemKind<CredentialSecret>
{
    internal static CredentialKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.Credential;

    /// <inheritdoc />
    public string Noun => "credential";

    /// <inheritdoc />
    public OpenedItem<CredentialSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<CredentialSecret>(document.Credential, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        CredentialSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        CredentialCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing, and for this type there was never a candidate.
    /// </summary>
    /// <remarks>
    /// A key at least has a fingerprint, which is public by nature and which this client still declines to
    /// send. A password has no part that is safe to expose — not its length, not a hash, not a hint — so
    /// there is no decision to make here. The server refuses plaintext fields on this type outright.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(CredentialSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<CredentialSecret> Merge(
        CredentialSecret ancestor,
        CredentialSecret local,
        CredentialSecret remote)
    {
        var merged = CredentialSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<CredentialSecret>(merged.Merged, merged.Conflicts);
    }

    /// <inheritdoc />
    public IReadOnlyList<string> Changes(CredentialSecret before, CredentialSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);
        Note(changed, "Password", before.Password, after.Password);
        Note(changed, "Username", before.Username, after.Username);
        Note(changed, "Notes", before.Notes, after.Notes);

        return changed;
    }

    /// <inheritdoc />
    public CredentialSecret Relabel(CredentialSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}

/// <summary>Known host keys.</summary>
internal sealed class KnownHostKeyKind : IItemKind<KnownHostSecret>
{
    internal static KnownHostKeyKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.KnownHostKey;

    /// <summary>
    /// What to call one of these to a person.
    /// </summary>
    /// <remarks>
    /// "Known host key" rather than "host key", because a user who reads "this host key could not be
    /// decrypted" would go looking at the host they are connecting to. The item is the record of a decision
    /// they made about that host, and the noun has to say so.
    /// </remarks>
    /// <inheritdoc />
    public string Noun => "known host key";

    /// <inheritdoc />
    public OpenedItem<KnownHostSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<KnownHostSecret>(document.KnownHost, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        KnownHostSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        KnownHostKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing, and here the temptation is the strongest of the four.
    /// </summary>
    /// <remarks>
    /// This item holds an address and a fingerprint, and neither is confidential in itself: the operator
    /// published the fingerprint and the address is one the server may already hold for a relay-enabled host.
    /// Handing them over anyway would tell the operator which machines each user actually connects to and
    /// when they first did — a map of the estate assembled out of individually harmless facts, for columns
    /// that do not exist and nothing that would read them. See ADR 0004.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(KnownHostSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<KnownHostSecret> Merge(
        KnownHostSecret ancestor,
        KnownHostSecret local,
        KnownHostSecret remote)
    {
        var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<KnownHostSecret>(merged.Merged, merged.Conflicts);
    }

    /// <summary>
    /// The pin unchanged, because a pin has no name of its own to change.
    /// </summary>
    /// <remarks>
    /// The only caller is the reconciler's resurrection path, which renames rescued content so a user can see
    /// what happened to it. <see cref="KnownHostSecret.Label"/> is derived from the host, port and algorithm
    /// the pin is about, so there is nothing here that a rename could move: renaming it would mean claiming
    /// the pin is about a different host. The resurrected item still gets its own id and still produces a
    /// conflict notice, so the event is visible — the notice simply names the pin the same way twice.
    /// </remarks>
    /// <remarks>
    /// In practice only the fingerprint can change: the store rewrites one or creates a new item, and never
    /// re-addresses an existing pin. The other three are compared anyway, because a payload from elsewhere
    /// is untrusted input and a silently unreported change to what a pin is <em>about</em> is the one thing
    /// an audit trail must not miss.
    /// </remarks>
    /// <inheritdoc />
    public IReadOnlyList<string> Changes(KnownHostSecret before, KnownHostSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Host", before.Host, after.Host);
        Note(changed, "Port", before.Port, after.Port);
        Note(changed, "Algorithm", before.Algorithm, after.Algorithm);
        Note(changed, "Fingerprint", before.Fingerprint, after.Fingerprint);

        return changed;
    }

    /// <inheritdoc />
    public KnownHostSecret Relabel(KnownHostSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret;
    }
}

/// <summary>Host groups.</summary>
internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
{
    internal static HostGroupKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.HostGroup;

    /// <inheritdoc />
    public string Noun => "group";

    /// <inheritdoc />
    public OpenedItem<HostGroupSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = HostGroupCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<HostGroupSecret>(document.Group, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        HostGroupSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing, and the two fields it declines to send are the two this type could have filled.
    /// </summary>
    /// <remarks>
    /// <c>SyncPlaintextFields</c> has a <c>GroupId</c> and a <c>ParentId</c>, the server had a column for the
    /// first, and no client ever wrote either. What they would hand over is the shape of the estate — which
    /// machines this user files together, and which of those groupings sit under which — for columns nothing
    /// in the product reads. Groups nest now and the parent is still not sent: it lives in the payload, and
    /// the server refuses the plaintext field outright, on hosts as well as here. See ADR 0004.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<HostGroupSecret> Merge(
        HostGroupSecret ancestor,
        HostGroupSecret local,
        HostGroupSecret remote)
    {
        var merged = HostGroupSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<HostGroupSecret>(merged.Merged, merged.Conflicts);
    }

    /// <inheritdoc />
    public IReadOnlyList<string> Changes(HostGroupSecret before, HostGroupSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);
        Note(changed, "Parent", before.ParentId, after.ParentId);
        Note(changed, "Default port", before.DefaultPort, after.DefaultPort);
        Note(changed, "Default username", before.DefaultUsername, after.DefaultUsername);
        Note(changed, "Default SSH key", before.DefaultSshKeyId, after.DefaultSshKeyId);
        Note(changed, "Default credential", before.DefaultCredentialId, after.DefaultCredentialId);

        return changed;
    }

    /// <inheritdoc />
    public HostGroupSecret Relabel(HostGroupSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}

/// <summary>Tags.</summary>
internal sealed class TagKind : IItemKind<TagSecret>
{
    internal static TagKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.Tag;

    /// <inheritdoc />
    public string Noun => "tag";

    /// <inheritdoc />
    public OpenedItem<TagSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = TagCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null ? null : new OpenedItem<TagSecret>(document.Tag, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        TagSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        TagCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing, and null rather than an empty <see cref="SyncPlaintextFields"/>.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The distinction is not pedantry. An empty record still serialises <c>relayEnabled: false</c>, which
    /// invites every reader of the wire — including a future implementation of this client — to conclude
    /// that a tag has a relay setting and that it is switched off. Null says the type has no plaintext at
    /// all, which is the true statement.
    /// </para>
    /// <para>
    /// A tag name in the clear would be the most aggregable column in the schema. Groups file machines;
    /// tags describe them — "pci", "customer-a", "eu-west" — and one tag spans twenty hosts by design, so a
    /// column here would hand the operator a labelled map of every user's estate for a sort no server in
    /// this product performs. See ADR 0004.
    /// </para>
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(TagSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<TagSecret> Merge(TagSecret ancestor, TagSecret local, TagSecret remote)
    {
        var merged = TagSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<TagSecret>(merged.Merged, merged.Conflicts);
    }

    /// <inheritdoc />
    public IReadOnlyList<string> Changes(TagSecret before, TagSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);

        return changed;
    }

    /// <inheritdoc />
    public TagSecret Relabel(TagSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}

/// <summary>Snippets.</summary>
internal sealed class SnippetKind : IItemKind<SnippetSecret>
{
    internal static SnippetKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.Snippet;

    /// <inheritdoc />
    public string Noun => "snippet";

    /// <inheritdoc />
    public OpenedItem<SnippetSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = SnippetCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<SnippetSecret>(document.Snippet, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        SnippetSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        SnippetCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing. A command is a description of the estate as precise as a hostname is.
    /// </summary>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(SnippetSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<SnippetSecret> Merge(
        SnippetSecret ancestor,
        SnippetSecret local,
        SnippetSecret remote)
    {
        var merged = SnippetSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<SnippetSecret>(merged.Merged, merged.Conflicts);
    }

    /// <inheritdoc />
    public IReadOnlyList<string> Changes(SnippetSecret before, SnippetSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);
        Note(changed, "Command", before.Command, after.Command);
        Note(changed, "Notes", before.Notes, after.Notes);

        // Named for what it means rather than after the property, because this is the line somebody
        // reviewing a log actually needs to see: a snippet that has been turned into one that runs.
        Note(changed, "Runs on insert", before.RunsOnInsert, after.RunsOnInsert);

        return changed;
    }

    /// <inheritdoc />
    public SnippetSecret Relabel(SnippetSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}

/// <summary>Connection log entries.</summary>
internal sealed class ConnectionLogEntryKind : IItemKind<ConnectionLogSecret>
{
    internal static ConnectionLogEntryKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.ConnectionLogEntry;

    /// <summary>What to call one of these to a person.</summary>
    /// <remarks>
    /// "Log entry" and not "connection". A user told that "this connection could not be decrypted" would go
    /// looking at a machine they cannot reach; the item is the <em>record</em> of one, and the noun has to
    /// say so.
    /// </remarks>
    public string Noun => "log entry";

    /// <inheritdoc />
    public OpenedItem<ConnectionLogSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = ConnectionLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<ConnectionLogSecret>(document.Entry, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        ConnectionLogSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        ConnectionLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing, and here the temptation is a timestamp rather than an address.
    /// </summary>
    /// <remarks>
    /// A plaintext <c>startedAt</c> would let the server order and prune a log without a client's help, which
    /// is genuinely useful and is exactly the wrong trade: a timestamp column on this table is a record of
    /// when each user works, assembled for free. The client sorts and prunes its own log. See ADR 0004.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(ConnectionLogSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<ConnectionLogSecret> Merge(
        ConnectionLogSecret ancestor,
        ConnectionLogSecret local,
        ConnectionLogSecret remote)
    {
        var merged = ConnectionLogSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<ConnectionLogSecret>(merged.Merged, merged.Conflicts);
    }

    /// <summary>Never asked, because a log entry is never audited or updated.</summary>
    /// <inheritdoc />
    public IReadOnlyList<string> Changes(ConnectionLogSecret before, ConnectionLogSecret after) => [];

    /// <summary>
    /// False, and this is the guard that stops the log logging itself.
    /// </summary>
    /// <remarks>
    /// The activity hook lives in the one generic repository every kind writes through, so without this a
    /// connection entry would produce an activity entry, which would produce another, without end. It is a
    /// property of the kind rather than a check at the call site so that the next machine-written kind
    /// cannot re-enter the loop by being overlooked.
    /// </remarks>
    public bool IsAudited => false;

    /// <summary>
    /// The entry unchanged, because an entry has no name of its own to change.
    /// </summary>
    /// <remarks>
    /// As for a pinned host key: the label is derived from what the entry records, so renaming it would mean
    /// claiming the connection was to a different machine. A resurrected entry still gets its own id and
    /// still produces a conflict notice, so the event stays visible.
    /// </remarks>
    /// <inheritdoc />
    public ConnectionLogSecret Relabel(ConnectionLogSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret;
    }
}

/// <summary>Activity log entries.</summary>
internal sealed class ActivityLogEntryKind : IItemKind<ActivityLogSecret>
{
    internal static ActivityLogEntryKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.ActivityLogEntry;

    /// <inheritdoc />
    public string Noun => "log entry";

    /// <inheritdoc />
    public OpenedItem<ActivityLogSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = ActivityLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<ActivityLogSecret>(document.Entry, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        ActivityLogSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        ActivityLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing. <c>SyncPlaintextFields.Kind</c> would fit and is refused for it.
    /// </summary>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(ActivityLogSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<ActivityLogSecret> Merge(
        ActivityLogSecret ancestor,
        ActivityLogSecret local,
        ActivityLogSecret remote)
    {
        var merged = ActivityLogSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<ActivityLogSecret>(merged.Merged, merged.Conflicts);
    }

    /// <inheritdoc cref="ConnectionLogEntryKind.Changes" />
    public IReadOnlyList<string> Changes(ActivityLogSecret before, ActivityLogSecret after) => [];

    /// <inheritdoc cref="ConnectionLogEntryKind.IsAudited" />
    public bool IsAudited => false;

    /// <inheritdoc cref="ConnectionLogEntryKind.Relabel" />
    public ActivityLogSecret Relabel(ActivityLogSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret;
    }
}

/// <summary>Buckets.</summary>
internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
{
    internal static ObjectStoreKind Instance { get; } = new();

    /// <inheritdoc />
    public SyncEntityType EntityType => SyncEntityType.ObjectStore;

    /// <summary>What to call one of these to a person.</summary>
    /// <remarks>
    /// "Bucket" rather than "object store", because that is the word on the screen and in every service's own
    /// documentation. The type is named for the protocol; the noun is named for what people say.
    /// </remarks>
    public string Noun => "bucket";

    /// <inheritdoc />
    public OpenedItem<ObjectStoreSecret>? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        var document = ObjectStoreCipher.TryOpen(payload, vaultKey, entityId, itemVersion);

        return document is null
            ? null
            : new OpenedItem<ObjectStoreSecret>(document.Store, document.IsReadOnly);
    }

    /// <inheritdoc />
    public EncryptedPayload Seal(
        ObjectStoreSecret secret,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion) =>
        ObjectStoreCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);

    /// <summary>
    /// Nothing, and for this type there was never a candidate.
    /// </summary>
    /// <remarks>
    /// The endpoint is the field somebody might reach for, and it is the one that must not go: for everybody
    /// self-hosting it is an address on their own network, which is exactly what the host table only holds in
    /// the clear when the relay cannot work without it. Nothing on the server dials a bucket.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Fields(ObjectStoreSecret secret) => null;

    /// <inheritdoc />
    public MergedItem<ObjectStoreSecret> Merge(
        ObjectStoreSecret ancestor,
        ObjectStoreSecret local,
        ObjectStoreSecret remote)
    {
        var merged = ObjectStoreSecretMerge.Merge(ancestor, local, remote);

        return new MergedItem<ObjectStoreSecret>(merged.Merged, merged.Conflicts);
    }

    /// <remarks>
    /// The secret access key is compared and never reported by value, which is the rule every credential-like
    /// field in this file follows. The access key id is shown: it is an identifier, not a secret.
    /// </remarks>
    /// <inheritdoc />
    public IReadOnlyList<string> Changes(ObjectStoreSecret before, ObjectStoreSecret after)
    {
        ArgumentNullException.ThrowIfNull(before);
        ArgumentNullException.ThrowIfNull(after);

        var changed = new List<string>();

        Note(changed, "Name", before.Label, after.Label);
        Note(changed, "Bucket", before.Bucket, after.Bucket);
        Note(changed, "Access key id", before.AccessKeyId, after.AccessKeyId);
        Note(changed, "Secret access key", before.SecretAccessKey, after.SecretAccessKey);
        Note(changed, "Region", before.Region, after.Region);
        Note(changed, "Endpoint", before.Endpoint, after.Endpoint);
        Note(changed, "Path-style addressing", before.UsePathStyle, after.UsePathStyle);
        Note(changed, "Notes", before.Notes, after.Notes);

        return changed;
    }

    /// <inheritdoc />
    public ObjectStoreSecret Relabel(ObjectStoreSecret secret, string label)
    {
        ArgumentNullException.ThrowIfNull(secret);

        return secret with { Label = label };
    }
}
