using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;

/// <summary>
///     Tests for <see cref="SubscriptionRegistry"/> — the bookkeeping the EventPump
///     uses to fan one OnDataChange event out to every driver subscription that
///     observes the changed item handle.
/// </summary>
public sealed class SubscriptionRegistryTests
{
    /// <summary>Verifies NextSubscriptionId() increments monotonically.</summary>
    [Fact]
    public void NextSubscriptionId_IsMonotonic()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.NextSubscriptionId().ShouldBe(1);
        registry.NextSubscriptionId().ShouldBe(2);
        registry.NextSubscriptionId().ShouldBe(3);
    }

    /// <summary>Verifies Register() and ResolveSubscribers() correctly store and return a single subscription.</summary>
    [Fact]
    public void Register_OneSubscription_OneTag_ResolvesSingleSubscriber()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(42, [new TagBindingAccess("Tank.Level", 100)]);

        var subs = registry.ResolveSubscribers(100);
        subs.Count.ShouldBe(1);
        subs[0].SubscriptionId.ShouldBe(42);
        subs[0].FullReference.ShouldBe("Tank.Level");
    }

    /// <summary>Verifies multiple subscriptions to the same tag are both indexed for fan-out.</summary>
    [Fact]
    public void Register_TwoSubscriptions_SameTag_FanOutToBoth()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);
        registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]);

        var subs = registry.ResolveSubscribers(100);
        subs.Count.ShouldBe(2);
        subs.Select(s => s.SubscriptionId).OrderBy(x => x).ShouldBe(new[] { 1L, 2L });
    }

    /// <summary>Verifies failed item handles (rejected by gateway) are not indexed for fan-out.</summary>
    [Fact]
    public void Register_FailedItemHandle_NotIndexedForFanOut()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [
            new TagBindingAccess("Good", 100),
            new TagBindingAccess("Bad", 0),  // gw rejected this tag
        ]);

        registry.ResolveSubscribers(100).Count.ShouldBe(1);
        registry.ResolveSubscribers(0).ShouldBeEmpty();
    }

    /// <summary>Verifies Remove() drops all bindings and returns them.</summary>
    [Fact]
    public void Remove_DropsAllBindings_AndReturnsThemForUnsubscribe()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [
            new TagBindingAccess("A", 100),
            new TagBindingAccess("B", 200),
        ]);

        var removed = registry.Remove(1);

        removed.ShouldNotBeNull();
        removed!.Count.ShouldBe(2);
        registry.ResolveSubscribers(100).ShouldBeEmpty();
        registry.ResolveSubscribers(200).ShouldBeEmpty();
    }

    /// <summary>Verifies removing one subscription of multiple leaves others intact.</summary>
    [Fact]
    public void Remove_OneOfTwoSubscriptions_LeavesOtherIntact()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [new TagBindingAccess("A", 100)]);
        registry.Register(2, [new TagBindingAccess("A", 100)]);

        registry.Remove(1);

        var subs = registry.ResolveSubscribers(100);
        subs.Count.ShouldBe(1);
        subs[0].SubscriptionId.ShouldBe(2);
    }

    /// <summary>Verifies removing an unknown subscription returns null without error.</summary>
    [Fact]
    public void Remove_UnknownSubscription_IsNullSentinel()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Remove(999).ShouldBeNull();
    }

    /// <summary>Verifies TrackedSubscriptionCount and TrackedItemHandleCount reflect registrations and removals.</summary>
    [Fact]
    public void TrackedCounts_ReflectAdditionsAndRemovals()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.TrackedSubscriptionCount.ShouldBe(0);

        registry.Register(1, [new TagBindingAccess("A", 100)]);
        registry.Register(2, [new TagBindingAccess("A", 100), new TagBindingAccess("B", 200)]);
        registry.TrackedSubscriptionCount.ShouldBe(2);
        registry.TrackedItemHandleCount.ShouldBe(2);

        registry.Remove(1);
        registry.TrackedSubscriptionCount.ShouldBe(1);
        registry.TrackedItemHandleCount.ShouldBe(2); // sub 2 still observes both handles

        registry.Remove(2);
        registry.TrackedSubscriptionCount.ShouldBe(0);
        registry.TrackedItemHandleCount.ShouldBe(0);
    }

    // ===== Driver.Galaxy-008 regression: reconnect replay rebinds with fresh handles =====

    /// <summary>Verifies SnapshotEntries() groups bindings correctly by subscription ID.</summary>
    [Fact]
    public void SnapshotEntries_GroupsBindingsBySubscriptionId()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [new TagBindingAccess("A", 100)]);
        registry.Register(2, [new TagBindingAccess("B", 200), new TagBindingAccess("C", 300)]);

        var entries = registry.SnapshotEntries();

        entries.Count.ShouldBe(2);
        entries.Single(e => e.SubscriptionId == 1).Bindings.Count.ShouldBe(1);
        entries.Single(e => e.SubscriptionId == 2).Bindings.Count.ShouldBe(2);
    }

    /// <summary>Verifies Rebind() replaces stale handles with fresh post-reconnect handles.</summary>
    [Fact]
    public void Rebind_ReplacesStaleItemHandles_WithThePostReconnectHandles()
    {
        // Before reconnect the gw assigned handle 100; after reconnect it issues 555.
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);

        registry.Rebind(1, [new TagBindingAccess("Tank.Level", 555)]);

        // The stale handle no longer fans out; the fresh handle does.
        registry.ResolveSubscribers(100).ShouldBeEmpty();
        var subs = registry.ResolveSubscribers(555);
        subs.Count.ShouldBe(1);
        subs[0].SubscriptionId.ShouldBe(1);
        subs[0].FullReference.ShouldBe("Tank.Level");
    }

    /// <summary>Verifies Rebind() on one subscription does not affect others on the same old handle.</summary>
    [Fact]
    public void Rebind_LeavesOtherSubscriptionsOnTheSameOldHandleIntact()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);
        registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]);

        // Only subscription 1 replays onto a fresh handle.
        registry.Rebind(1, [new TagBindingAccess("Tank.Level", 555)]);

        registry.ResolveSubscribers(100).Select(s => s.SubscriptionId).ShouldBe(new[] { 2L });
        registry.ResolveSubscribers(555).Select(s => s.SubscriptionId).ShouldBe(new[] { 1L });
    }

    /// <summary>Verifies Rebind() on an unknown subscription is a no-op.</summary>
    [Fact]
    public void Rebind_UnknownSubscription_IsNoOp()
    {
        var registry = new SubscriptionRegistryAccess();
        Should.NotThrow(() => registry.Rebind(999, [new TagBindingAccess("X", 1)]));
        registry.ResolveSubscribers(1).ShouldBeEmpty();
    }

    /// <summary>Verifies Rebind() does not index rejected item handles.</summary>
    [Fact]
    public void Rebind_FailedItemHandle_NotIndexedForFanOut()
    {
        var registry = new SubscriptionRegistryAccess();
        registry.Register(1, [new TagBindingAccess("Tag", 100)]);

        // Post-reconnect the gw rejected the tag — handle 0.
        registry.Rebind(1, [new TagBindingAccess("Tag", 0)]);

        registry.ResolveSubscribers(100).ShouldBeEmpty();
        registry.ResolveSubscribers(0).ShouldBeEmpty();
    }

    // ===== Driver.Galaxy-012 regression: ResolveSubscribers is O(1) per binding =====

    /// <summary>Verifies ResolveSubscribers() correctly dispatches in a large binding set without linear scan.</summary>
    [Fact]
    public void ResolveSubscribers_LargeBindingSet_DispatchesCorrectly()
    {
        // 5000-tag subscription. ResolveSubscribers must still return the right
        // full-reference for any item handle without a linear scan of the entire
        // binding list — the old FirstOrDefault(b => b.ItemHandle == h) was O(n)
        // per dispatch, so 50k tags × 1Hz fan-out was 50k linear scans per second.
        var registry = new SubscriptionRegistryAccess();
        const int tagCount = 5000;
        var bindings = new List<TagBindingAccess>(tagCount);
        for (var i = 0; i < tagCount; i++)
        {
            bindings.Add(new TagBindingAccess($"Tag.{i}", 1000 + i));
        }
        registry.Register(1, bindings);

        // Pull the last entry — the worst case for a linear scan.
        var subs = registry.ResolveSubscribers(1000 + tagCount - 1);

        subs.Count.ShouldBe(1);
        subs[0].FullReference.ShouldBe($"Tag.{tagCount - 1}");

        // Mid-range entry too — proves the index isn't position-dependent.
        var mid = registry.ResolveSubscribers(1000 + tagCount / 2);
        mid.Count.ShouldBe(1);
        mid[0].FullReference.ShouldBe($"Tag.{tagCount / 2}");
    }

    // Internal types are accessed via friend assembly (InternalsVisibleTo); these
    // wrapper aliases keep the test code readable.
    /// <summary>Wrapper for accessing SubscriptionRegistry in tests via internal visibility.</summary>
    private sealed class SubscriptionRegistryAccess
    {
        /// <summary>The underlying SubscriptionRegistry instance.</summary>
        private readonly SubscriptionRegistry _inner = new();

        /// <summary>Gets the count of tracked subscriptions.</summary>
        public int TrackedSubscriptionCount => _inner.TrackedSubscriptionCount;

        /// <summary>Gets the count of tracked item handles.</summary>
        public int TrackedItemHandleCount => _inner.TrackedItemHandleCount;

        /// <summary>Gets the next subscription ID.</summary>
        public long NextSubscriptionId() => _inner.NextSubscriptionId();

        /// <summary>Registers a subscription with the given bindings.</summary>
        /// <param name="id">The subscription ID.</param>
        /// <param name="bindings">The tag bindings to register.</param>
        public void Register(long id, IReadOnlyList<TagBindingAccess> bindings)
            => _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);

        /// <summary>Removes a subscription and returns its bindings.</summary>
        /// <param name="id">The subscription ID.</param>
        /// <returns>The bindings that were removed, or null if not found.</returns>
        public IReadOnlyList<TagBindingAccess>? Remove(long id)
        {
            var removed = _inner.Remove(id);
            return removed is null ? null : [.. removed.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))];
        }

        /// <summary>Resolves subscribers for the given item handle.</summary>
        /// <param name="handle">The item handle to look up.</param>
        /// <returns>The list of subscriptions observing this handle.</returns>
        public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle)
            => _inner.ResolveSubscribers(handle);

        /// <summary>Rebinds a subscription to new item handles.</summary>
        /// <param name="id">The subscription ID.</param>
        /// <param name="bindings">The new tag bindings.</param>
        public void Rebind(long id, IReadOnlyList<TagBindingAccess> bindings)
            => _inner.Rebind(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);

        /// <summary>Snapshots all subscription entries grouped by ID.</summary>
        /// <returns>All subscription entries with their bindings.</returns>
        public IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBindingAccess> Bindings)> SnapshotEntries()
            => [.. _inner.SnapshotEntries().Select(e =>
                (e.SubscriptionId,
                 (IReadOnlyList<TagBindingAccess>)[.. e.Bindings.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))]))];
    }
    private sealed record TagBindingAccess(string FullReference, int ItemHandle);
}
