using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;

/// <summary>
/// Caches one <see cref="SiteStreamGrpcClient"/> per <c>(site, endpoint)</c> pair.
/// The DebugStreamBridgeActor uses this factory to obtain (or create) a
/// gRPC client for a given site+node before opening a streaming subscription.
/// <para>
/// Both of a site's node channels (NodeA and NodeB) coexist in the cache, so a
/// per-session NodeA↔NodeB failover flip never disposes a channel another debug
/// session is still using (arch review 02, High). <see cref="GetOrCreate"/> no
/// longer disposes on endpoint mismatch; site *removal* (<see cref="RemoveSiteAsync"/>)
/// is the only shared-disposal path — it disposes every endpoint for that site.
/// </para>
/// <para>
/// Trade-off: an edited gRPC address leaves the old endpoint's now-idle channel
/// cached until site removal or process shutdown — bounded at a handful of entries
/// per site (2 nodes + rare edits), each an idle HTTP/2 channel.
/// </para>
/// </summary>
public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
{
    private readonly ConcurrentDictionary<(string Site, string Endpoint), SiteStreamGrpcClient> _clients = new();
    private readonly ILoggerFactory _loggerFactory;
    private readonly CommunicationOptions _options;
    private readonly ISitePskProvider? _pskProvider;

    /// <summary>
    /// Test/default constructor — uses default <see cref="CommunicationOptions"/> and creates
    /// unauthenticated channels.
    /// </summary>
    /// <param name="loggerFactory">Logger factory passed to created clients.</param>
    public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory)
        : this(loggerFactory, Options.Create(new CommunicationOptions()))
    {
    }

    /// <summary>
    /// Constructor without a key provider — creates unauthenticated channels, which a gated
    /// site will refuse. Retained for tests and for hosts that never dial a site.
    /// </summary>
    /// <param name="loggerFactory">Logger factory passed to created clients.</param>
    /// <param name="options">Communication options applied to each created client.</param>
    public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory, IOptions<CommunicationOptions> options)
        : this(loggerFactory, options, pskProvider: null)
    {
    }

    /// <summary>
    /// DI constructor — flows <see cref="CommunicationOptions"/> into every created
    /// <see cref="SiteStreamGrpcClient"/> so the configured gRPC keepalive settings are applied
    /// rather than hard-coded defaults, and attaches the per-site preshared key that the site's
    /// <c>ControlPlaneAuthInterceptor</c> requires.
    /// </summary>
    /// <param name="loggerFactory">Logger factory passed to created clients.</param>
    /// <param name="options">Communication options applied to each created client.</param>
    /// <param name="pskProvider">
    /// Resolves each site's preshared key. Optional in DI so a host that registers no provider
    /// (a site node, which never dials another site) still resolves this factory.
    /// </param>
    public SiteStreamGrpcClientFactory(
        ILoggerFactory loggerFactory,
        IOptions<CommunicationOptions> options,
        ISitePskProvider? pskProvider)
    {
        _loggerFactory = loggerFactory;
        _options = options.Value;
        _pskProvider = pskProvider;
    }

    /// <summary>
    /// Returns the cached client for <c>(site, endpoint)</c>, or creates one. Both node
    /// channels for a site coexist under distinct keys, so requesting a different
    /// endpoint for the same site adds a second entry rather than disposing the first —
    /// per-session failover can never dispose a channel a concurrent session still holds.
    /// </summary>
    /// <param name="siteIdentifier">Unique site identifier (first half of the cache key).</param>
    /// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key) the client is bound to.</param>
    /// <returns>The cached or newly-created client bound to <paramref name="grpcEndpoint"/>.</returns>
    public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) =>
        _clients.GetOrAdd((siteIdentifier, grpcEndpoint), key => CreateClient(key.Site, key.Endpoint));

    /// <summary>
    /// Returns the cached client for <c>(site, endpoint)</c>, or <c>null</c> — never creates.
    /// Teardown paths (DebugStreamBridgeActor.CleanupGrpc / failed-stream unsubscribe)
    /// use this so cleanup can never open a fresh channel or touch another endpoint's client.
    /// </summary>
    /// <param name="siteIdentifier">Unique site identifier (first half of the cache key).</param>
    /// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key).</param>
    /// <returns>The cached client, or <c>null</c> when no client is cached for the pair.</returns>
    public virtual SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) =>
        _clients.TryGetValue((siteIdentifier, grpcEndpoint), out var client) ? client : null;

    /// <summary>
    /// Creates a single <see cref="SiteStreamGrpcClient"/>. Overridable so tests
    /// can substitute a tracking client while still exercising the factory's real
    /// caching and disposal machinery.
    /// </summary>
    /// <param name="siteIdentifier">Site the new client talks to; selects which preshared key it presents.</param>
    /// <param name="grpcEndpoint">gRPC endpoint the new client will connect to.</param>
    /// <returns>A new <see cref="SiteStreamGrpcClient"/> connected to <paramref name="grpcEndpoint"/>.</returns>
    protected virtual SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
    {
        var logger = _loggerFactory.CreateLogger<SiteStreamGrpcClient>();
        return new SiteStreamGrpcClient(grpcEndpoint, logger, _options, _pskProvider, siteIdentifier);
    }

    /// <summary>
    /// Removes and disposes every cached client for the given site — all of its node
    /// endpoints. This is the only shared-disposal path: call it when a site record is
    /// deleted so the site's gRPC channels do not linger for the life of the process.
    /// </summary>
    /// <param name="siteIdentifier">Unique site identifier whose clients should be removed.</param>
    /// <returns>A task that completes when all of the site's cached clients have been disposed.</returns>
    public async Task RemoveSiteAsync(string siteIdentifier)
    {
        foreach (var key in _clients.Keys.Where(k => k.Site == siteIdentifier).ToList())
        {
            if (_clients.TryRemove(key, out var client))
                await client.DisposeAsync();
        }

        // Drop the cached preshared key too, so a site removed and re-added under the same
        // identifier (with a rotated key) is not dialed with the stale one.
        _pskProvider?.Invalidate(siteIdentifier);
    }

    /// <summary>
    /// Asynchronously disposes all cached clients and clears the cache.
    /// </summary>
    /// <returns>A value task that completes when all clients have been disposed.</returns>
    public async ValueTask DisposeAsync()
    {
        foreach (var client in _clients.Values)
        {
            await client.DisposeAsync();
        }
        _clients.Clear();
    }

    /// <summary>
    /// Synchronous disposal. This used to block on
    /// <c>DisposeAsync().AsTask().GetAwaiter().GetResult()</c> (sync-over-async,
    /// a stall/deadlock risk during host shutdown). Each
    /// <see cref="SiteStreamGrpcClient"/> releases all of its resources
    /// synchronously, so we dispose them directly with no async path.
    /// </summary>
    public void Dispose()
    {
        foreach (var client in _clients.Values)
        {
            client.Dispose();
        }
        _clients.Clear();
    }
}
