namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;

/// <summary>
///     A singleton, in-process fan-out for live event streams (alarm transitions, script-log
///     lines). A per-node SignalR bridge actor subscribes to the cluster's DistributedPubSub topic
///     and calls <see cref="Publish"/>; Blazor Server components subscribe to <see cref="Received"/>
///     to render the live tail.
///     <para>
///     This exists because the AdminUI runs as Blazor <em>Server</em>: a component opening a
///     SignalR <c>HubConnection</c> to its own hub would dial the browser's public URL from
///     server-side code, which is unreachable behind a reverse proxy (e.g. Traefik mapping host
///     :9200 → container :9000) and so fails with "Connection refused". Reading this in-process
///     broadcaster instead avoids the network hop entirely. Mirrors the
///     <c>IDriverStatusSnapshotStore.SnapshotChanged</c> pattern for stream (vs. last-value) feeds.
///     </para>
/// </summary>
/// <typeparam name="T">The event payload type (e.g. AlarmTransitionEvent, ScriptLogEntry).</typeparam>
public interface IInProcessBroadcaster<T>
{
    /// <summary>
    ///     Raised once per <see cref="Publish"/> with the published item. Handlers run on the
    ///     caller's thread (the bridge actor), so subscribers must marshal to their own sync
    ///     context (Blazor's <c>InvokeAsync</c>).
    /// </summary>
    event Action<T>? Received;

    /// <summary>Fan the item out to all current <see cref="Received"/> subscribers.</summary>
    /// <param name="item">The item to publish to subscribers.</param>
    void Publish(T item);

    /// <summary>
    ///     Whether the upstream feed (the per-node SignalR bridge's DPS subscription) is currently
    ///     live. Drives the "live" pill on the Blazor pages. False until the bridge's first
    ///     <c>SubscribeAck</c>; flips false again when the bridge stops.
    /// </summary>
    bool IsConnected { get; }

    /// <summary>
    ///     Raised whenever <see cref="IsConnected"/> changes (and only on change), with the new value.
    ///     Handlers run on the caller's thread (the bridge actor), so Blazor subscribers must marshal
    ///     via <c>InvokeAsync</c>.
    /// </summary>
    event Action<bool>? ConnectionStateChanged;

    /// <summary>
    ///     Set by the bridge actor from its DPS-subscription health: <c>true</c> on <c>SubscribeAck</c>
    ///     (subscription live), <c>false</c> on <c>PostStop</c>/failure. Raises
    ///     <see cref="ConnectionStateChanged"/> only when the value actually changes.
    /// </summary>
    /// <param name="connected">The new connection state.</param>
    void SetConnected(bool connected);
}

/// <summary>Thread-safe singleton implementation of <see cref="IInProcessBroadcaster{T}"/>.</summary>
/// <typeparam name="T">The event payload type.</typeparam>
public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
{
    // Guards _isConnected: the bridge actor sets it on the actor thread; Blazor reads it on the
    // render thread, so access must be serialised.
    private readonly object _connectionLock = new();
    private bool _isConnected;

    /// <inheritdoc />
    public event Action<T>? Received;

    /// <inheritdoc />
    public event Action<bool>? ConnectionStateChanged;

    /// <inheritdoc />
    // Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise.
    public void Publish(T item) => Received?.Invoke(item);

    /// <inheritdoc />
    public bool IsConnected
    {
        get
        {
            lock (_connectionLock)
            {
                return _isConnected;
            }
        }
    }

    /// <inheritdoc />
    public void SetConnected(bool connected)
    {
        Action<bool>? handler;
        lock (_connectionLock)
        {
            if (_isConnected == connected)
            {
                return;
            }

            _isConnected = connected;
            // Capture inside the lock, invoke outside (mirrors Publish) so a concurrent
            // unsubscribe can't null the delegate mid-raise and we never hold the lock during a callback.
            handler = ConnectionStateChanged;
        }

        handler?.Invoke(connected);
    }
}
