using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Threading.Channels;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using Microsoft.Extensions.Options;

namespace DodoSSH.Api.Features.Events;

/// <summary>
/// Tells connected clients that something they can read has moved.
/// </summary>
/// <remarks>
/// <para>
/// Every method is <c>void</c> and returns having queued, never having sent. That is the contract, not
/// an implementation detail: the callers are write paths that have just committed a transaction, and a
/// publish that could block on a slow socket would make one client's bad network everybody else's
/// latency. A notice that cannot be queued is dropped, which is safe because the client polls anyway.
/// See ADR 0012.
/// </para>
/// <para>
/// An interface because this is the seam a multi-node backplane implements — PostgreSQL
/// <c>LISTEN</c>/<c>NOTIFY</c> is the obvious one and needs no infrastructure this stack does not
/// already run. It is deliberately not implemented: fan-out today is in-process, so a deployment with
/// more than one API replica notices writes handled by other replicas on the polling interval rather
/// than at once. That is the behaviour before this feature existed, which is why it degrades rather
/// than breaks.
/// </para>
/// </remarks>
public interface IVaultEventPublisher
{
    /// <summary>Announces that a vault's change log has reached <paramref name="sequence"/>.</summary>
    /// <remarks>
    /// Call <em>after</em> the transaction commits, and outside the per-vault advisory lock ADR 0003
    /// takes. A notice sent from inside names a sequence no reader can see yet, and holds the vault's
    /// write lock across a socket write.
    /// </remarks>
    void VaultChanged(Guid vaultId, long sequence);

    /// <summary>Announces that the set of vaults an account can reach is no longer what it was.</summary>
    /// <remarks>
    /// Takes the <em>recipient</em>, not the actor. Sharing is something one account does to another's
    /// list, and it is the other account that has to re-read.
    /// </remarks>
    void VaultAccessChanged(Guid userId);
}

/// <summary>
/// Every event socket this node is holding.
/// </summary>
/// <remarks>
/// <para>
/// Publishing walks the whole connection list and asks each one whether it cares, rather than keeping
/// an index from vault to subscribers. With a per-node connection cap in the hundreds and an event rate
/// bounded by how often people edit keychains, the walk is not measurable — and the index is not free:
/// a connection's vault set is re-resolved while it is live, so every re-subscription would have to
/// move it between buckets under a lock that publishing also takes. The simpler shape is the one whose
/// races are obvious.
/// </para>
/// <para>
/// A singleton, holding no scoped service and no database context. Connections outlive requests by
/// design and anything request-scoped they captured would outlive its scope with them.
/// </para>
/// </remarks>
internal sealed class VaultEventHub(
    IOptions<EventsOptions> options,
    TimeProvider clock,
    ILogger<VaultEventHub> logger) : IVaultEventPublisher
{
    private readonly ConcurrentDictionary<Guid, VaultEventConnection> connections = new();

    /// <summary>
    /// Serialises admission so the caps are caps rather than approximations.
    /// </summary>
    /// <remarks>
    /// Counting and inserting under one lock, because the two done separately let N simultaneous
    /// connects all read the same under-cap count and all insert. Contended only by connects, which
    /// happen once per client per session; publishing never takes it.
    /// </remarks>
    private readonly Lock admission = new();

    /// <summary>How many sockets this node is holding. Diagnostics and tests.</summary>
    internal int Count => connections.Count;

    /// <summary>
    /// Admits a socket, or refuses it because a cap is already met.
    /// </summary>
    /// <returns>The connection, or null when a limit refused it.</returns>
    internal VaultEventConnection? TryAdmit(Guid userId, FrozenSet<Guid> vaults)
    {
        var limits = options.Value;

        lock (admission)
        {
            if (connections.Count >= limits.MaxConnectionsTotal)
            {
                EventsLog.ConnectionRefused(logger, userId, "the node limit");
                return null;
            }

            var held = 0;
            foreach (var existing in connections.Values)
            {
                if (existing.UserId == userId && ++held >= limits.MaxConnectionsPerUser)
                {
                    EventsLog.ConnectionRefused(logger, userId, "the per-account limit");
                    return null;
                }
            }

            var connection = new VaultEventConnection(userId, vaults, limits.OutboundQueueDepth);

            // Cannot collide: the id is fresh and this is the only insert.
            connections[connection.Id] = connection;

            EventsLog.ConnectionOpened(logger, userId, vaults.Count, connections.Count);

            return connection;
        }
    }

    /// <summary>Forgets a socket that has closed.</summary>
    internal void Remove(VaultEventConnection connection)
    {
        ArgumentNullException.ThrowIfNull(connection);

        connections.TryRemove(connection.Id, out _);
        connection.Complete();

        EventsLog.ConnectionClosed(logger, connection.UserId, connections.Count);
    }

    /// <inheritdoc />
    public void VaultChanged(Guid vaultId, long sequence)
    {
        var notice = new VaultEvent(
            VaultEventKinds.VaultChanged,
            VaultId: vaultId,
            Sequence: sequence,
            ServerTime: clock.GetUtcNow());

        var delivered = 0;

        foreach (var connection in connections.Values)
        {
            if (connection.IsSubscribedTo(vaultId) && connection.TryEnqueue(notice))
            {
                delivered++;
            }
        }

        if (delivered > 0)
        {
            EventsLog.VaultChangePublished(logger, vaultId, sequence, delivered);
        }
    }

    /// <inheritdoc />
    public void VaultAccessChanged(Guid userId)
    {
        var notice = new VaultEvent(VaultEventKinds.VaultsChanged, ServerTime: clock.GetUtcNow());

        var delivered = 0;

        foreach (var connection in connections.Values)
        {
            if (connection.UserId == userId && connection.TryEnqueue(notice))
            {
                delivered++;
            }
        }

        if (delivered > 0)
        {
            EventsLog.AccessChangePublished(logger, userId, delivered);
        }
    }
}

/// <summary>
/// One open socket, as the hub sees it.
/// </summary>
/// <remarks>
/// Deliberately knows nothing about WebSockets. The hub queues frames here and the endpoint's pump
/// takes them away, which is what keeps a publish from ever touching a socket — and what lets the
/// whole fan-out be tested without one.
/// </remarks>
internal sealed class VaultEventConnection
{
    private readonly Channel<VaultEvent> outbound;

    private FrozenSet<Guid> vaults;

    internal VaultEventConnection(Guid userId, FrozenSet<Guid> vaults, int queueDepth)
    {
        Id = Guid.CreateVersion7();
        UserId = userId;
        this.vaults = vaults;

        // DropOldest, and the choice is what makes a slow reader harmless. A notice says "vault X has
        // moved to at least sequence N", so a newer one subsumes the one it displaces and the client's
        // answer — pull that vault — is identical either way. The writer therefore never waits and
        // TryWrite never fails, which is what lets the publish path be non-blocking and void.
        outbound = Channel.CreateBounded<VaultEvent>(new BoundedChannelOptions(queueDepth)
        {
            FullMode = BoundedChannelFullMode.DropOldest,
            SingleReader = true,
            SingleWriter = false,
        });
    }

    /// <summary>Identifies this connection within the hub. Never sent to a client.</summary>
    internal Guid Id { get; }

    /// <summary>The account that opened it.</summary>
    internal Guid UserId { get; }

    /// <summary>Frames waiting to be written to the socket.</summary>
    internal ChannelReader<VaultEvent> Outbound => outbound.Reader;

    /// <summary>How many vaults this socket currently follows.</summary>
    internal int VaultCount => Volatile.Read(ref vaults).Count;

    /// <summary>Whether a change to this vault concerns this socket.</summary>
    internal bool IsSubscribedTo(Guid vaultId) => Volatile.Read(ref vaults).Contains(vaultId);

    /// <summary>
    /// Replaces what this socket follows, after its account's access was re-resolved.
    /// </summary>
    /// <remarks>
    /// A whole-set swap of an immutable set rather than a mutation, so a publish walking the list
    /// concurrently reads either the old set or the new one and never a half-built one. No lock: the
    /// only writer is this connection's own pump.
    /// </remarks>
    internal void Resubscribe(FrozenSet<Guid> replacement) => Volatile.Write(ref vaults, replacement);

    /// <summary>Queues a frame. Never blocks, and never fails — see the channel's full mode.</summary>
    internal bool TryEnqueue(VaultEvent frame) => outbound.Writer.TryWrite(frame);

    /// <summary>Signals that nothing more will be queued, which ends the pump's drain loop.</summary>
    internal void Complete() => outbound.Writer.TryComplete();
}
