using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Api.Features.Sync;

/// <summary>
/// Everything about one item type that the shared write path cannot know.
/// </summary>
/// <remarks>
/// <para>
/// The push path is the most dangerous code in the server: it holds the version check, the change-log
/// append, the receipt that makes a retry exactly-once, and the advisory lock that keeps the sequence in
/// commit order. Duplicating that per item type would mean ten copies of it by M5, and a fix applied to nine
/// of them. So it is written once over <see cref="IVaultItem"/>, and everything genuinely type-specific —
/// which table, which plaintext columns, what those columns must satisfy — arrives through here.
/// </para>
/// <para>
/// Deliberately not generic. A generic <c>IItemKind&lt;TItem&gt;</c> reads better in isolation and would
/// force every method on the write path to be generic too, including the async ones, for no benefit: the
/// path never needs the concrete type, only the shared shape. The downcast each implementation performs is
/// contained to one class per type and is guaranteed by construction, because the same class both creates
/// and queries the rows.
/// </para>
/// </remarks>
internal interface IItemKind
{
    /// <summary>The type as the wire contract names it.</summary>
    SyncEntityType WireType { get; }

    /// <summary>The type as the change log names it.</summary>
    /// <remarks>
    /// Stated rather than cast. The two enums do agree numerically — and they have different member names
    /// for the same value, <c>Host</c> against <c>SshHost</c> — but that alignment is hand-kept and nothing
    /// in the type system defends it. Writing it out per kind means adding a type cannot silently file its
    /// changes under another type's name.
    /// </remarks>
    ChangeEntityType ChangeType { get; }

    /// <summary>Finds one item by id, across every vault, so a cross-vault id can be refused.</summary>
    Task<IVaultItem?> FindAsync(DodoDbContext database, Guid id, CancellationToken cancellationToken);

    /// <summary>Loads the rows behind a set of change-log entries, scoped to one vault.</summary>
    Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken);

    /// <summary>Creates an empty row of this type and tracks it.</summary>
    IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId);

    /// <summary>Rejects plaintext fields this type may not carry.</summary>
    bool ValidateFields(SyncPlaintextFields fields, out string error);

    /// <summary>Copies this type's plaintext columns out of the request.</summary>
    void ApplyFields(IVaultItem item, SyncPlaintextFields fields);

    /// <summary>Clears plaintext columns that must not outlive the item.</summary>
    void ClearFieldsOnDelete(IVaultItem item);

    /// <summary>Reads this type's plaintext columns back for a pull, or null when it has none.</summary>
    SyncPlaintextFields? Hydrate(IVaultItem item);
}

/// <summary>The item types this server can synchronise, by wire type.</summary>
internal static class ItemKinds
{
    private static readonly Dictionary<SyncEntityType, IItemKind> Supported =
        new[]
        {
            (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
            new HostGroupKind(), new TagKind(), new SnippetKind(),
            new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
        }.ToDictionary(kind => kind.WireType);

    /// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
    /// <remarks>
    /// Null rather than a throw. Every entity type in the contract is reachable by a newer client, and the
    /// push endpoint answers per operation — so an unsupported type has to become one <c>Invalid</c> result
    /// with a reason, not a failed batch that also rejects the operations this server did understand.
    /// </remarks>
    internal static IItemKind? For(SyncEntityType type) =>
        Supported.TryGetValue(type, out var kind) ? kind : null;
}

/// <summary>Hosts: the one item type with a deliberate plaintext concession.</summary>
internal sealed class HostKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.Host;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.SshHost;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.Hosts.SingleOrDefaultAsync(h => h.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.Hosts
            .Where(h => h.VaultId == vaultId && ids.Contains(h.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var host = new SshHost { Id = id, VaultId = vaultId };

        database.Hosts.Add(host);

        return host;
    }

    /// <summary>
    /// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
    /// violation surfacing as a 500.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The group check comes <em>first</em>, ahead of the relay branch, and that placement is the point: the
    /// relay branch returns early on its happy path, so a check placed after it would apply to non-relay
    /// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely
    /// to carry it.
    /// </para>
    /// <para>
    /// <c>SyncPlaintextFields.GroupId</c> is part of a frozen wire contract and cannot be removed from it, so
    /// refusing it here is what actually keeps the value out of the database. The column it used to be copied
    /// into was dropped when groups landed; see <see cref="VaultHostGroup"/> for why membership travels inside
    /// the payload instead.
    /// </para>
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.GroupId is not null)
        {
            error = "A host's group is inside its encrypted payload; the server does not store one.";
            return false;
        }

        if (fields.RelayEnabled)
        {
            if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
            {
                error = "Relay-enabled hosts require both a hostname and a port.";
                return false;
            }

            if (fields.Port is < 1 or > 65535)
            {
                error = "Port must be between 1 and 65535.";
                return false;
            }

            return true;
        }

        if (fields.Hostname is not null || fields.Port is not null)
        {
            error = "A hostname or port may only be supplied when relay is enabled for the host.";
            return false;
        }

        return true;
    }

    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
        ArgumentNullException.ThrowIfNull(fields);

        var host = (SshHost)item;

        host.RelayEnabled = fields.RelayEnabled;
        host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
        host.Port = fields.RelayEnabled ? fields.Port : null;
    }

    /// <remarks>
    /// The address goes with the item. Leaving it would keep the server able to resolve a host the user
    /// believes they deleted.
    /// </remarks>
    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
        var host = (SshHost)item;

        host.RelayEnabled = false;
        host.Hostname = null;
        host.Port = null;
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item)
    {
        var host = (SshHost)item;

        return new SyncPlaintextFields(
            RelayEnabled: host.RelayEnabled,
            Hostname: host.Hostname,
            Port: host.Port);
    }
}

/// <summary>SSH keys: ciphertext and nothing else.</summary>
internal sealed class SshKeyKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.SshKey;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.SshKey;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.SshKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.SshKeys
            .Where(k => k.VaultId == vaultId && ids.Contains(k.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var key = new VaultSshKey { Id = id, VaultId = vaultId };

        database.SshKeys.Add(key);

        return key;
    }

    /// <summary>
    /// Refuses the relay columns outright.
    /// </summary>
    /// <remarks>
    /// A key is not something the server dials, so an address on one is either a client bug or an attempt to
    /// get the server to store something it has no reason to hold. Refused with a reason rather than
    /// silently dropped, because a client that thinks it is storing a field and is not will eventually be
    /// surprised by its absence.
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "An SSH key has no relay target; relay fields may only be set on a host.";
            return false;
        }

        return true;
    }

    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
        ArgumentNullException.ThrowIfNull(fields);

        // Only the fingerprint, and only because the contract reserved it. Everything that identifies the
        // key to a person — its label, its comment — is inside the ciphertext.
        ((VaultSshKey)item).PublicKeyFingerprint = fields.PublicKeyFingerprint;
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item) =>
        ((VaultSshKey)item).PublicKeyFingerprint = null;

    /// <remarks>
    /// Null rather than an all-defaults instance, and the difference is visible on the wire: a
    /// <c>SyncPlaintextFields</c> with nothing set still serialises <c>relayEnabled: false</c>, which
    /// invites a reader to believe this type has a relay setting that happens to be off. It has none.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) =>
        ((VaultSshKey)item).PublicKeyFingerprint is { } fingerprint
            ? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint)
            : null;
}

/// <summary>Credentials: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as a kind gets about plaintext — <see cref="KnownHostKeyKind"/> is the other one — and the
/// reason is not symmetry. A key at least has a fingerprint that is public by nature; a password has no part
/// that is safe to expose, so this kind accepts no plaintext fields at all and hydrates none.
/// </remarks>
internal sealed class CredentialKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.Credential;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.Credential;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.Credentials.SingleOrDefaultAsync(c => c.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.Credentials
            .Where(c => c.VaultId == vaultId && ids.Contains(c.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var credential = new VaultCredential { Id = id, VaultId = vaultId };

        database.Credentials.Add(credential);

        return credential;
    }

    /// <summary>
    /// Refuses every plaintext field there is.
    /// </summary>
    /// <remarks>
    /// Refused with a reason rather than silently dropped, so a client that believes it is storing something
    /// finds out now rather than when the field turns out to be missing.
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A credential has no relay target; relay fields may only be set on a host.";
            return false;
        }

        if (fields.PublicKeyFingerprint is not null)
        {
            error = "A credential has no public key.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <remarks>
    /// Always null, which is a stronger statement than an empty record: this type has no plaintext columns,
    /// so there is nothing a pull could hydrate even in principle.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Known host keys: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="CredentialKind"/> about plaintext, for a reason that is about aggregation rather
/// than secrecy. A fingerprint is published so it can be compared and an address may already sit in a
/// relay-enabled host's columns — but the set of endpoints one user has approved is a map of their estate,
/// and this server has nothing to do with it.
/// </remarks>
internal sealed class KnownHostKeyKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.KnownHostKey;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.KnownHostKey;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.KnownHostKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.KnownHostKeys
            .Where(k => k.VaultId == vaultId && ids.Contains(k.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var knownHost = new VaultKnownHostKey { Id = id, VaultId = vaultId };

        database.KnownHostKeys.Add(knownHost);

        return knownHost;
    }

    /// <summary>
    /// Refuses every plaintext field there is.
    /// </summary>
    /// <remarks>
    /// The relay fields are refused although this type is the one that <em>does</em> hold an address, and
    /// that is the point: the address belongs in the ciphertext. A client sending it here is either confused
    /// or trying to get the server to keep a list it has no business keeping, and either way it should be
    /// told rather than have the value quietly dropped.
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A known host key is not something the server dials; its address stays encrypted.";
            return false;
        }

        if (fields.PublicKeyFingerprint is not null)
        {
            error = "A known host key's fingerprint stays inside its payload.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <remarks>
    /// Always null, as for a credential: this type has no plaintext columns, so there is nothing a pull could
    /// hydrate even in principle.
    /// </remarks>
    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Host groups: an envelope and nothing else.</summary>
/// <remarks>
/// The kind that closes a hole rather than opening one. <c>SyncPlaintextFields</c> has carried a
/// <c>GroupId</c> since the contract was frozen and <see cref="HostKind"/> used to copy it into a column;
/// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the
/// server can read, which is the same answer <see cref="KnownHostKeyKind"/> gives for the same reason.
/// </remarks>
internal sealed class HostGroupKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.HostGroup;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.HostGroup;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.HostGroups.SingleOrDefaultAsync(g => g.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.HostGroups
            .Where(g => g.VaultId == vaultId && ids.Contains(g.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var group = new VaultHostGroup { Id = id, VaultId = vaultId };

        database.HostGroups.Add(group);

        return group;
    }

    /// <summary>
    /// Refuses every plaintext field there is, including the two that would describe this type's own place
    /// in a tree.
    /// </summary>
    /// <remarks>
    /// <b>Groups nest, and the pointer that nests them is still refused here.</b> The refusal used to mean
    /// "there is no such thing as a parent"; it now means "the parent is not the server's to hold". A
    /// <c>ParentId</c> or <c>GroupId</c> column on this table would let the operator reconstruct the shape of
    /// every user's estate — how many groupings, how deep, which under which — which is precisely the
    /// disclosure ADR 0004 refuses everywhere except the one address the relay cannot dial without. The
    /// parent travels inside the envelope like the name beside it, and the client is the only thing that can
    /// read either.
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A host group is not something the server dials.";
            return false;
        }

        if (fields.GroupId is not null || fields.ParentId is not null)
        {
            error = "A host group's name and its place in the tree are inside its payload.";
            return false;
        }

        if (fields.PublicKeyFingerprint is not null)
        {
            error = "A host group has no public key.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Tags: an envelope and nothing else.</summary>
/// <remarks>
/// <see cref="HostGroupKind"/>'s answer to the same question, at higher stakes. A group name says how one
/// user files their machines; a tag name says what the machines are, and one tag is meant to span twenty of
/// them — so a plaintext label here would hand the operator a labelled map of every estate on the server, to
/// sort a list nothing server-side draws.
/// </remarks>
internal sealed class TagKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.Tag;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.Tag;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.Tags.SingleOrDefaultAsync(t => t.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.Tags
            .Where(t => t.VaultId == vaultId && ids.Contains(t.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var tag = new VaultTag { Id = id, VaultId = vaultId };

        database.Tags.Add(tag);

        return tag;
    }

    /// <summary>
    /// Refuses every plaintext field there is, including the one a join table would have reached for.
    /// </summary>
    /// <remarks>
    /// <b><c>RelatedId</c> matters more here than for any other kind.</b> It is the obvious place to put "the
    /// host this tag is on", which is exactly what <c>SyncEntityType.HostTag</c> reserves a slot for and what
    /// this design decided not to build — membership is a set inside each host's payload. A future client
    /// reaching for the field would be storing the host-to-tag graph in the clear, one row at a time, which
    /// is the aggregation this kind exists to refuse. <c>SyncPlaintextFields</c> is frozen so the field
    /// cannot be removed; refusing it is the only place the decision can be enforced.
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A tag is not something the server dials.";
            return false;
        }

        if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
        {
            error = "A tag's name is inside its payload, and which hosts wear it is inside theirs.";
            return false;
        }

        if (fields.PublicKeyFingerprint is not null)
        {
            error = "A tag has no public key.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Snippets: an envelope and nothing else.</summary>
/// <remarks>
/// A label column here would sort a list this server never draws, and the commands beside that label describe
/// the estate as precisely as a list of hostnames would. So this kind is as strict as
/// <see cref="CredentialKind"/>, and for the aggregation reason rather than the secrecy one.
/// </remarks>
internal sealed class SnippetKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.Snippet;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.Snippet;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.Snippets.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.Snippets
            .Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var snippet = new VaultSnippet { Id = id, VaultId = vaultId };

        database.Snippets.Add(snippet);

        return snippet;
    }

    /// <summary>Refuses every plaintext field there is.</summary>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A snippet is not something the server dials.";
            return false;
        }

        if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
        {
            error = "A snippet's contents, including anything it is scoped to, stay inside its payload.";
            return false;
        }

        if (fields.PublicKeyFingerprint is not null)
        {
            error = "A snippet has no public key.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Connection log entries: an envelope and nothing else.</summary>
/// <remarks>
/// <para>
/// The strictest kind here, and the one where a plaintext column would have been most tempting: a
/// <c>started_at</c> would let this server order and prune a log without any client's help. It gets none,
/// because a timestamp column on this table is a record of when each user works, and the times are the
/// interesting part of a connection log even when the hostnames are sealed.
/// </para>
/// <para>
/// <b>The server cannot enforce write-once, and does not pretend to.</b> That an entry is created and never
/// updated is a client rule — see <see cref="VaultConnectionLogEntry"/> — and the shared write path would
/// accept an upsert with a correct <c>expectedVersion</c> like any other. Adding a refusal here would be a
/// guarantee about payload semantics this server cannot read.
/// </para>
/// </remarks>
internal sealed class ConnectionLogEntryKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.ConnectionLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.ConnectionLog
            .Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId };

        database.ConnectionLog.Add(entry);

        return entry;
    }

    /// <summary>Refuses every plaintext field there is.</summary>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A log entry is not something the server dials; what was connected to stays encrypted.";
            return false;
        }

        if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
        {
            error = "A log entry names what it is about inside its payload.";
            return false;
        }

        if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
        {
            error = "A log entry carries no plaintext fields at all.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Activity log entries: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="ConnectionLogEntryKind"/>. <c>SyncPlaintextFields.Kind</c> exists and would fit
/// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording
/// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts
/// that each look harmless.
/// </remarks>
internal sealed class ActivityLogEntryKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.ActivityLogEntry;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.ActivityLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.ActivityLog
            .Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId };

        database.ActivityLog.Add(entry);

        return entry;
    }

    /// <summary>Refuses every plaintext field there is.</summary>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A log entry is not something the server dials.";
            return false;
        }

        if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
        {
            error = "Which item a log entry is about stays inside its payload.";
            return false;
        }

        if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
        {
            error = "A log entry carries no plaintext fields at all.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}

/// <summary>Object stores: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="CredentialKind"/>, because it holds the same class of thing. A secret access key
/// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The
/// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to
/// weigh.
/// </remarks>
internal sealed class ObjectStoreKind : IItemKind
{
    /// <inheritdoc />
    public SyncEntityType WireType => SyncEntityType.ObjectStore;

    /// <inheritdoc />
    public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore;

    /// <inheritdoc />
    public async Task<IVaultItem?> FindAsync(
        DodoDbContext database,
        Guid id,
        CancellationToken cancellationToken) =>
        await database.ObjectStores.SingleOrDefaultAsync(o => o.Id == id, cancellationToken)
            .ConfigureAwait(false);

    /// <inheritdoc />
    public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
        DodoDbContext database,
        Guid vaultId,
        Guid[] ids,
        CancellationToken cancellationToken)
    {
        var rows = await database.ObjectStores
            .Where(o => o.VaultId == vaultId && ids.Contains(o.Id))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
    }

    /// <inheritdoc />
    public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
    {
        var store = new VaultObjectStore { Id = id, VaultId = vaultId };

        database.ObjectStores.Add(store);

        return store;
    }

    /// <summary>Refuses every plaintext field there is.</summary>
    /// <remarks>
    /// The relay fields are refused although this type <em>does</em> hold an address, exactly as they are for
    /// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either
    /// confused or trying to get the server to keep a list of where its users store data.
    /// </remarks>
    /// <inheritdoc />
    public bool ValidateFields(SyncPlaintextFields fields, out string error)
    {
        ArgumentNullException.ThrowIfNull(fields);

        error = string.Empty;

        if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
        {
            error = "A bucket is not something the server dials; its endpoint stays encrypted.";
            return false;
        }

        if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
        {
            error = "A bucket's contents are inside its payload.";
            return false;
        }

        if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
        {
            error = "A bucket carries no plaintext fields at all.";
            return false;
        }

        return true;
    }

    /// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
    /// <inheritdoc />
    public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
    {
    }

    /// <inheritdoc />
    public void ClearFieldsOnDelete(IVaultItem item)
    {
    }

    /// <inheritdoc />
    public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
