using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;

namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;

/// <summary>
///     Unit tests for <see cref="MxAccessHandleRegistry"/>. The registry gained a
///     reverse (server handle, item definition) index, memoized sorted views, and
///     secondary removal indexes so bulk reads and bulk teardown stop rescanning
///     the whole handle table. These tests pin the observable behaviour those
///     structures must preserve: the same lookup semantics the old linear scan
///     had, snapshot views that only rebuild after a mutation, and removals that
///     leave every index consistent.
/// </summary>
public sealed class MxAccessHandleRegistryTests
{
    /// <summary>
    ///     Verifies the reverse index answers by server handle and ordinal item
    ///     definition, returns every duplicate registration in ascending item
    ///     handle order (the order a scan of <see cref="MxAccessHandleRegistry.ItemHandles"/>
    ///     would have visited them), and misses on the wrong server, a different
    ///     tag, and a case-differing tag.
    /// </summary>
    [Fact]
    public void GetItemHandlesForDefinition_MatchesServerAndOrdinalTag_InAscendingHandleOrder()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
        registry.RegisterServerHandle(serverHandle: 2, clientName: "client");

        // Same tag added twice under server 1 — MXAccess hands back a distinct
        // item handle per AddItem, so the index must keep both, lowest first.
        registry.RegisterItemHandle(1, itemHandle: 40, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(2, itemHandle: 12, "Tank1.PV", string.Empty, hasItemContext: false);

        Assert.Equal(new[] { 10, 40 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
        Assert.Equal(new[] { 11 }, registry.GetItemHandlesForDefinition(1, "Tank1.SP"));
        Assert.Equal(new[] { 12 }, registry.GetItemHandlesForDefinition(2, "Tank1.PV"));

        // Misses: unknown server, unknown tag, and a case-differing tag — the
        // read path compares tag addresses ordinally, so casing must not match.
        Assert.Empty(registry.GetItemHandlesForDefinition(3, "Tank1.PV"));
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank9.PV"));
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "tank1.pv"));
    }

    /// <summary>
    ///     Verifies re-registering an item handle under a new item definition
    ///     retires the old reverse-index entry instead of leaving a stale hit.
    /// </summary>
    [Fact]
    public void RegisterItemHandle_ReusedHandleWithNewDefinition_RetiresStaleIndexEntry()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank2.PV", string.Empty, hasItemContext: false);

        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
        Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
        Assert.Single(registry.ItemHandles);
    }

    /// <summary>
    ///     Verifies the reverse index drops handles as they are removed, both for
    ///     a single item removal and for a whole-server teardown.
    /// </summary>
    [Fact]
    public void GetItemHandlesForDefinition_AfterRemoval_MissesRemovedHandles()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.PV", string.Empty, hasItemContext: false);

        registry.RemoveItemHandle(1, itemHandle: 10);
        Assert.Equal(new[] { 11 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV"));

        registry.UnregisterServerHandle(1);
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
    }

    /// <summary>
    ///     Verifies each sorted view is memoized: repeated reads hand back the
    ///     same instance until a mutation of that table invalidates it, and the
    ///     rebuilt view reflects the mutation while keeping its sort order.
    /// </summary>
    [Fact]
    public void Views_AreMemoizedUntilTheirTableMutates()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterServerHandle(serverHandle: 2, clientName: "client");
        registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
        registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.SP", string.Empty, hasItemContext: false);
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory);
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);

        IReadOnlyList<RegisteredServerHandle> servers = registry.ServerHandles;
        IReadOnlyList<RegisteredItemHandle> items = registry.ItemHandles;
        IReadOnlyList<RegisteredAdviceHandle> advices = registry.AdviceHandles;

        // No mutation in between: the memoized arrays are handed back as-is.
        Assert.Same(servers, registry.ServerHandles);
        Assert.Same(items, registry.ItemHandles);
        Assert.Same(advices, registry.AdviceHandles);

        // Sort orders are unchanged by memoization.
        Assert.Equal(new[] { 1, 2 }, Map(servers, handle => handle.ServerHandle));
        Assert.Equal(new[] { 10, 11 }, Map(items, handle => handle.ItemHandle));
        Assert.Equal(
            new[] { MxAccessAdviceKind.Plain, MxAccessAdviceKind.Supervisory },
            Map(advices, handle => handle.AdviceKind));

        // A mutation of one table invalidates that view only.
        registry.RegisterServerHandle(serverHandle: 3, clientName: "client");
        Assert.NotSame(servers, registry.ServerHandles);
        Assert.Equal(3, registry.ServerHandles.Count);
        Assert.Same(items, registry.ItemHandles);
        Assert.Same(advices, registry.AdviceHandles);

        registry.RemoveItemHandle(1, itemHandle: 11);
        Assert.NotSame(items, registry.ItemHandles);
        Assert.Single(registry.ItemHandles);

        registry.RemoveAdviceHandles(1, itemHandle: 10);
        Assert.NotSame(advices, registry.AdviceHandles);
        Assert.Empty(registry.AdviceHandles);
    }

    /// <summary>
    ///     Verifies removing an item also removes every advice recorded for it and
    ///     nothing recorded for a sibling item — the bulk unadvise/remove path
    ///     leans on this to leave a consistent table.
    /// </summary>
    [Fact]
    public void RemoveItemHandle_RemovesOnlyThatItemsAdvices()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false);
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory);
        registry.RegisterAdviceHandle(1, itemHandle: 11, MxAccessAdviceKind.Plain);

        registry.RemoveItemHandle(1, itemHandle: 10);

        Assert.False(registry.ContainsItemHandle(1, 10));
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory));
        Assert.True(registry.ContainsItemHandle(1, 11));
        Assert.True(registry.ContainsAdviceHandle(1, 11, MxAccessAdviceKind.Plain));
        Assert.Single(registry.AdviceHandles);
    }

    /// <summary>
    ///     Verifies a bulk unadvise followed by a bulk remove drains the registry
    ///     completely, so the indexed removals cannot leave orphaned entries that
    ///     a later lookup would resurrect.
    /// </summary>
    [Fact]
    public void BulkUnadviseThenRemove_LeavesRegistryEmpty()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterServerHandle(serverHandle: 1, clientName: "client");

        for (int itemHandle = 1; itemHandle <= 50; itemHandle++)
        {
            registry.RegisterItemHandle(1, itemHandle, "Tank." + itemHandle, string.Empty, hasItemContext: false);
            registry.RegisterAdviceHandle(1, itemHandle, MxAccessAdviceKind.Plain);
        }

        for (int itemHandle = 1; itemHandle <= 50; itemHandle++)
        {
            registry.RemoveAdviceHandles(1, itemHandle);
        }

        Assert.Empty(registry.AdviceHandles);
        Assert.Equal(50, registry.ItemHandles.Count);

        for (int itemHandle = 1; itemHandle <= 50; itemHandle++)
        {
            registry.RemoveItemHandle(1, itemHandle);
        }

        Assert.Empty(registry.ItemHandles);
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank.25"));
        Assert.True(registry.ContainsServerHandle(1));
    }

    /// <summary>
    ///     Verifies unregistering a server drops its items and advices and leaves
    ///     every other server's handles untouched, including an advice registered
    ///     for an item that was never added to the item table.
    /// </summary>
    [Fact]
    public void UnregisterServerHandle_RemovesOnlyThatServersHandles()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterServerHandle(serverHandle: 1, clientName: "client-one");
        registry.RegisterServerHandle(serverHandle: 2, clientName: "client-two");

        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false);
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);

        // Advice without a matching item registration: the old scan removed it by
        // server handle, so the per-server index must reach it too.
        registry.RegisterAdviceHandle(1, itemHandle: 99, MxAccessAdviceKind.Supervisory);

        // Server 2 reuses the same item handle values — packing must keep them apart.
        registry.RegisterItemHandle(2, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterAdviceHandle(2, itemHandle: 10, MxAccessAdviceKind.Plain);

        registry.UnregisterServerHandle(1);

        Assert.False(registry.ContainsServerHandle(1));
        Assert.False(registry.ContainsItemHandle(1, 10));
        Assert.False(registry.ContainsItemHandle(1, 11));
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
        Assert.False(registry.ContainsAdviceHandle(1, 99, MxAccessAdviceKind.Supervisory));
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));

        Assert.True(registry.ContainsServerHandle(2));
        Assert.True(registry.ContainsItemHandle(2, 10));
        Assert.True(registry.ContainsAdviceHandle(2, 10, MxAccessAdviceKind.Plain));
        Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(2, "Tank1.PV"));
        Assert.Single(registry.ItemHandles);
        Assert.Single(registry.AdviceHandles);
        Assert.Single(registry.ServerHandles);
    }

    /// <summary>
    ///     Verifies re-registering an advice that is already present does not
    ///     duplicate it in the per-item removal index — a duplicate would survive
    ///     the removal that drains that index.
    /// </summary>
    [Fact]
    public void RegisterAdviceHandle_RegisteredTwice_StillRemovedByOneCall()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);

        Assert.Single(registry.AdviceHandles);

        registry.RemoveAdviceHandles(1, itemHandle: 10);

        Assert.Empty(registry.AdviceHandles);
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));

        // Re-advising after the removal must work off a clean index.
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
        Assert.Single(registry.AdviceHandles);
        Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
    }

    /// <summary>
    ///     Pins the registry half of <c>MxAccessSession.TryGetCachedReadFor</c>'s fall-through
    ///     contract. That scan walks <see cref="MxAccessHandleRegistry.GetItemHandlesForDefinition"/>
    ///     in order and skips any candidate carrying neither a plain nor a supervisory advice,
    ///     because an added-but-unadvised item will never receive a fresh <c>OnDataChange</c> and so
    ///     can only serve a stale cache entry. The registry has to make that skip possible: the
    ///     duplicate registrations of one tag must come back in a stable ascending order, and the
    ///     advice index must answer per item handle rather than per tag. Asserted here rather than
    ///     on the session because the session's read path needs a live MXAccess COM instance.
    /// </summary>
    [Fact]
    public void GetItemHandlesForDefinition_MultipleCandidates_AdviceIndexDiscriminatesTheAdvisedOne()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterServerHandle(serverHandle: 1, clientName: "client");

        // Same tag under two item handles: 10 is added but never advised, 20 is advised. Registered
        // out of order so the ascending-order guarantee is doing real work.
        registry.RegisterItemHandle(1, itemHandle: 20, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        registry.RegisterAdviceHandle(1, itemHandle: 20, MxAccessAdviceKind.Plain);

        IReadOnlyList<int> candidates = registry.GetItemHandlesForDefinition(1, "Tank1.PV");

        Assert.Equal(new[] { 10, 20 }, candidates);

        // The unadvised candidate is visited first and skipped; the advised one is the survivor.
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory));
        Assert.True(registry.ContainsAdviceHandle(1, 20, MxAccessAdviceKind.Plain));

        // Supervisory alone qualifies too, so a later advise on 10 makes it the first survivor.
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory);
        Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory));
    }

    /// <summary>
    ///     One adversarial lifecycle run over every index at once: register, advise, re-register the
    ///     same item handle under a new tag, unadvise, then tear the server down. Each individual
    ///     transition is covered above; this pins that they compose — the reverse definition index,
    ///     the per-item advice index and the per-server removal index must agree after every step,
    ///     since a stale entry in any one of them resurrects a handle MXAccess has already retired.
    /// </summary>
    [Fact]
    public void RegisterAdviseReregisterUnadviseUnregister_LeavesEveryIndexConsistent()
    {
        MxAccessHandleRegistry registry = new();
        registry.RegisterServerHandle(serverHandle: 1, clientName: "client");

        // Register.
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
        Assert.True(registry.ContainsItemHandle(1, 10));
        Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
        Assert.Empty(registry.AdviceHandles);

        // Advise.
        registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
        Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
        Assert.Single(registry.AdviceHandles);

        // Re-register the SAME item handle under a new tag. The advice is keyed on the item handle,
        // not the tag, so it survives — but the old definition entry must not.
        registry.RegisterItemHandle(1, itemHandle: 10, "Tank2.PV", string.Empty, hasItemContext: false);
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
        Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
        Assert.Single(registry.ItemHandles);
        Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));

        // Unadvise: the item stays registered and still resolves by its current tag.
        registry.RemoveAdviceHandles(1, itemHandle: 10);
        Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
        Assert.Empty(registry.AdviceHandles);
        Assert.True(registry.ContainsItemHandle(1, 10));
        Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV"));

        // Unregister the server: every index drains, including the definition index the re-register
        // rewrote.
        registry.UnregisterServerHandle(1);
        Assert.False(registry.ContainsServerHandle(1));
        Assert.False(registry.ContainsItemHandle(1, 10));
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
        Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
        Assert.Empty(registry.ServerHandles);
        Assert.Empty(registry.ItemHandles);
        Assert.Empty(registry.AdviceHandles);
    }

    private static List<TResult> Map<TSource, TResult>(
        IReadOnlyList<TSource> source,
        Func<TSource, TResult> selector)
    {
        List<TResult> mapped = new(source.Count);

        for (int index = 0; index < source.Count; index++)
        {
            mapped.Add(selector(source[index]));
        }

        return mapped;
    }
}
