namespace Mbproxy.Proxy.Multiplexing;

/// <summary>
/// Per-PLC "in-flight by key" map that powers read coalescing. Holds the currently-
/// in-flight FC03/FC04 requests keyed by their <see cref="CoalescingKey"/> so a
/// late-arriving request with an identical key can attach to the existing in-flight entry
/// instead of opening a second backend round-trip.
///
/// <para><b>Concurrency model.</b> A single <see cref="object"/> lock serialises every
/// state-touching method. The simpler-lock-over-CAS choice is deliberate — the map is
/// per-PLC and the wire rate per PLC is bounded by the ECOM's internal scan cadence
/// (~2–10 ms per request). The lock-free <c>AddOrUpdate</c> alternative is not worth the
/// read-and-prove-it-correct burden.</para>
///
/// <para><b>Mutable-list seam.</b> Each entry stores a <see cref="List{InterestedParty}"/>
/// that is also exposed through the parent <see cref="InFlightRequest.InterestedParties"/>
/// property (typed as <c>IReadOnlyList&lt;InterestedParty&gt;</c>). Attaches mutate this
/// list under the map lock. The backend reader task removes the entry from the map BEFORE
/// it iterates the list during fan-out, which guarantees no concurrent mutation during
/// iteration: by the time the reader holds the (just-removed) list, no future attach can
/// find the key and therefore no further appends can occur.</para>
///
/// <para><b>Race-on-remove:</b> the reader removes from <see cref="CorrelationMap"/> first,
/// then from this map. A late attach in between will append a new party to the same list
/// (whose response has just arrived); the reader's fan-out loop will then deliver to that
/// party too. The reverse race (reader removes from this map first, then attach arrives)
/// is impossible because the reader's <see cref="TryRemove"/> takes the lock — any in-flight
/// attach is serialised before or after.</para>
/// </summary>
internal sealed class InFlightByKeyMap
{
    private readonly object _lock = new();
    private readonly Dictionary<CoalescingKey, InFlightRequest> _entries = new();

    /// <summary>Current entry count. Read under the lock for a stable snapshot.</summary>
    public int Count
    {
        get { lock (_lock) { return _entries.Count; } }
    }

    /// <summary>
    /// Atomic attach-or-create. If <paramref name="key"/> already maps to an in-flight
    /// entry whose interested-party list has fewer than <paramref name="maxParties"/>
    /// entries, appends <paramref name="party"/> to that entry and returns
    /// <c>wasNew = false</c>. Otherwise invokes <paramref name="factory"/> to build a fresh
    /// <see cref="InFlightRequest"/>, stores it under <paramref name="key"/>, and returns
    /// <c>wasNew = true</c>.
    ///
    /// <para>The factory must build a request whose <c>InterestedParties</c> list is a
    /// <see cref="List{T}"/> (cast to <see cref="IReadOnlyList{T}"/>). The map relies on
    /// being able to <see cref="List{T}.Add(T)"/> to that same instance under its lock when
    /// later attaches arrive.</para>
    ///
    /// <para><b>maxParties cap</b> — load-shedding safety valve. If an existing entry
    /// already has <paramref name="maxParties"/> attached parties, the next arrival opens
    /// a fresh entry (and a fresh backend round-trip). This bounds the response-fanout
    /// cost per entry at O(maxParties).</para>
    /// </summary>
    public void AttachOrCreate(
        CoalescingKey key,
        InterestedParty party,
        Func<InFlightRequest> factory,
        int maxParties,
        out InFlightRequest req,
        out bool wasNew)
    {
        lock (_lock)
        {
            if (_entries.TryGetValue(key, out var existing)
                && existing.InterestedParties is List<InterestedParty> existingList
                && existingList.Count < maxParties)
            {
                existingList.Add(party);
                req = existing;
                wasNew = false;
                return;
            }

            req = factory();
            _entries[key] = req;
            wasNew = true;
        }
    }

    /// <summary>
    /// Removes the entry under <paramref name="key"/> if present. Returns <c>false</c> when
    /// no entry exists — which is normal when the cascade path beat the reader to the entry
    /// or when a watchdog timeout removed the entry while a response was in flight.
    /// </summary>
    public bool TryRemove(CoalescingKey key, out InFlightRequest req)
    {
        lock (_lock)
        {
            if (_entries.TryGetValue(key, out var existing))
            {
                _entries.Remove(key);
                req = existing;
                return true;
            }
            req = default!;
            return false;
        }
    }

    /// <summary>
    /// Removes every entry. Used by the multiplexer's cascade path on backend disconnect.
    /// </summary>
    public IReadOnlyList<InFlightRequest> DrainAll()
    {
        lock (_lock)
        {
            var drained = new List<InFlightRequest>(_entries.Count);
            drained.AddRange(_entries.Values);
            _entries.Clear();
            return drained;
        }
    }
}
