using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;

// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;

namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;

/// <summary>
///     Covers <see cref="AliasTable"/> + <see cref="BirthCache"/> (Task 18) — design doc §3.6
///     invariants #1 and #2.
/// </summary>
/// <remarks>
///     <para>
///         <b>What is actually being pinned here.</b> Sparkplug's alias mechanism exists to shrink DATA
///         payloads: a BIRTH declares <c>name ↔ alias</c> and every subsequent DATA metric carries only
///         the alias. The alias is scoped to <i>one birth</i> — after a rebirth an edge node is free to
///         assign alias 5 to an entirely different metric. If this driver merged alias tables across
///         births, or bound authored tags to aliases rather than to the stable metric name, a rebirth
///         would silently start routing Pressure readings into a Temperature tag: <b>good quality,
///         plausible values, completely wrong</b>. It is invisible without a test that specifically
///         reuses an alias across a rebirth, which is what
///         <see cref="Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias"/> and
///         <see cref="DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName"/> do.
///     </para>
///     <para>
///         The cross-scope half of the same hazard is that a device's alias table can outlive the birth
///         it came from when its <i>edge node</i> re-births — pinned by
///         <see cref="NodeBirth_InvalidatesEveryDeviceUnderThatNode"/>.
///     </para>
/// </remarks>
public sealed class AliasBindingTests
{
    // ---- AliasTable: the two load-bearing invariants ----

    /// <summary>
    ///     §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different
    ///     metric, and the table must report the <b>new</b> metric — i.e. the birth replaced the map
    ///     rather than merging into it.
    /// </summary>
    [Fact]
    public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
    {
        var table = new AliasTable();

        table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
        table.Resolve(5)!.Name.ShouldBe("Temperature");

        // Rebirth: alias 5 now means a DIFFERENT metric.
        table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]);

        table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge
        table.TryResolveByName("Temperature", out _).ShouldBeFalse();
    }

    /// <summary>
    ///     §3.6 invariant #2: an alias the new birth did not declare must be <b>gone</b>, not carried
    ///     forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes.
    /// </summary>
    [Fact]
    public void RebuildFromBirth_DoesNotMergeStaleAliases()
    {
        var table = new AliasTable();

        table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]);
        table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]);

        table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
        table.TryResolve(2, out _).ShouldBeTrue();
        table.Count.ShouldBe(1);
    }

    /// <summary>
    ///     The degenerate replace path: a birth carrying no usable metric still <b>clears</b> the table.
    ///     An implementation that short-circuits on an empty input (or merges) keeps every stale alias
    ///     alive behind a birth that declared none of them.
    /// </summary>
    [Fact]
    public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias()
    {
        var table = new AliasTable();
        table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]);

        table.RebuildFromBirth([]);

        table.Count.ShouldBe(0);
        table.TryResolve(1, out _).ShouldBeFalse();
        table.TryResolve(2, out _).ShouldBeFalse();
        table.Metrics.ShouldBeEmpty();
    }

    /// <summary>
    ///     The mirror of the headline case: the <i>metric</i> stays put and its alias moves. Binding is
    ///     by name, so the tag keeps resolving; only the per-birth alias cache changed.
    /// </summary>
    [Fact]
    public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt()
    {
        var table = new AliasTable();
        table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);

        table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]);

        table.TryResolveByName("Temperature", out var byName).ShouldBeTrue();
        byName!.Alias.ShouldBe(9ul);
        table.Resolve(9)!.Name.ShouldBe("Temperature");
        table.TryResolve(5, out _).ShouldBeFalse();
    }

    // ---- AliasTable: malformed births ----

    /// <summary>
    ///     A BIRTH metric carrying an alias but no name is malformed and is <b>rejected</b>, never
    ///     stored alias-only: the name is the binding key, so an alias-only entry could only ever
    ///     resolve to a binding no authored tag can match — or, worse, match a tag whose authored
    ///     metric name happens to be blank.
    /// </summary>
    [Fact]
    public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly()
    {
        var table = new AliasTable();

        var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth("  ", 6, SparkplugDataType.Float)]);

        result.Accepted.ShouldBe(0);
        result.RejectedUnnamed.ShouldBe(2);
        result.HasAnomaly.ShouldBeTrue();
        table.TryResolve(5, out _).ShouldBeFalse();
        table.TryResolve(6, out _).ShouldBeFalse();
    }

    /// <summary>
    ///     Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then
    ///     send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears
    ///     in the alias index.
    /// </summary>
    [Fact]
    public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex()
    {
        var table = new AliasTable();

        table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]);

        table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
        binding!.Alias.ShouldBeNull();
        table.Metrics.Count.ShouldBe(1);
    }

    /// <summary>
    ///     Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin
    ///     flip between two different metrics — precisely the mis-route this whole type exists to
    ///     prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown
    ///     alias and can request a rebirth) while both metrics stay bindable by their names.
    /// </summary>
    [Fact]
    public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed()
    {
        var table = new AliasTable();

        var result = table.RebuildFromBirth(
            [Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]);

        result.AliasCollisions.ShouldBe(1);
        table.TryResolve(5, out _).ShouldBeFalse();
        table.TryResolveByName("Temperature", out _).ShouldBeTrue();
        table.TryResolveByName("Pressure", out _).ShouldBeTrue();
    }

    /// <summary>
    ///     Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting
    ///     it would take the tag dark entirely), reported so the consumer can warn.
    /// </summary>
    [Fact]
    public void DuplicateNameInOneBirth_IsLastWins_AndReported()
    {
        var table = new AliasTable();

        var result = table.RebuildFromBirth(
            [Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]);

        result.DuplicateNames.ShouldBe(1);
        result.Accepted.ShouldBe(1);
        table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
        binding!.Alias.ShouldBe(6ul);
    }

    /// <summary>
    ///     A BIRTH metric with no datatype is malformed, but it is <b>kept</b> as
    ///     <see cref="SparkplugDataType.Unknown"/> rather than rejected: rejecting it makes every DATA
    ///     message for its alias an unknown-alias event, which the ingest state machine answers with a
    ///     rebirth request — and the edge node would answer with the same malformed birth, forever. Kept
    ///     as Unknown, the typed layer refuses the value once (<c>ToDriverDataType()</c> returns null)
    ///     and nothing storms.
    /// </summary>
    [Fact]
    public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected()
    {
        var table = new AliasTable();

        var result = table.RebuildFromBirth([Untyped("Temperature", 5)]);

        result.Accepted.ShouldBe(1);
        result.RejectedUnnamed.ShouldBe(0);
        table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown);
    }

    /// <summary>
    ///     The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding
    ///     undoable: a DATA metric carries no datatype of its own, so the binding is the only place the
    ///     signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 —
    ///     plausible, Good quality, invisible.
    /// </summary>
    [Fact]
    public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype()
    {
        var table = new AliasTable();
        table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]);

        // What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype.
        var raw = unchecked((uint)-42);

        table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42);
    }

    /// <summary>A null birth sequence is a caller bug, not a birth — it must not silently clear the table.</summary>
    [Fact]
    public void RebuildFromBirth_Null_Throws() =>
        Should.Throw<ArgumentNullException>(() => new AliasTable().RebuildFromBirth(null!));

    // ---- BirthCache: scoping ----

    /// <summary>
    ///     Sparkplug spec: an NBIRTH means the edge node (re)started, so <b>every</b> DBIRTH previously
    ///     published under it is invalid until re-published. Keeping a device's alias table across its
    ///     node's rebirth is the same stale-alias mis-route as invariant #2, one scope up.
    /// </summary>
    [Fact]
    public void NodeBirth_InvalidatesEveryDeviceUnderThatNode()
    {
        var cache = new BirthCache();
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]);

        var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]);

        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull();
        cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();

        // The invalidated devices come back with their metric sets so the consumer can fan STALE out
        // without a lookup against a table that has already been evicted.
        outcome.InvalidatedDevices.Count.ShouldBe(2);
        outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name)
            .ShouldBe(["Temperature", "Speed"], ignoreOrder: true);
    }

    /// <summary>An NBIRTH is scoped to its own edge node — another node's devices are untouched.</summary>
    [Fact]
    public void NodeBirth_DoesNotTouchAnotherNodesDevices()
    {
        var cache = new BirthCache();
        cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);

        var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);

        outcome.InvalidatedDevices.ShouldBeEmpty();
        cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
    }

    /// <summary>A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs.</summary>
    [Fact]
    public void DeviceBirth_ReplacesOnlyThatDevicesTable()
    {
        var cache = new BirthCache();
        cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]);

        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]);

        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity");
        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed");
        cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime");
    }

    /// <summary>
    ///     Key completeness. A cache keyed on the device id alone — or on <c>node/device</c> without the
    ///     group — would let one plant's <c>Filler1</c> answer for another's, which is the same
    ///     wrong-but-plausible mis-route as an alias collision.
    /// </summary>
    [Fact]
    public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId()
    {
        var cache = new BirthCache();
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
        cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);

        cache.Count.ShouldBe(3);
        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature");
        cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure");
        cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed");
    }

    /// <summary>
    ///     §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the
    ///     cache and hands back the metric sets, which is what the consumer fans STALE out over.
    /// </summary>
    [Fact]
    public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut()
    {
        var cache = new BirthCache();
        cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);

        var removed = cache.ForgetNode("Plant1", "EdgeA");

        removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true);
        cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull();
        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
        cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
    }

    /// <summary>A DDEATH takes out exactly one device and leaves its node's own metrics live.</summary>
    [Fact]
    public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics()
    {
        var cache = new BirthCache();
        cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);

        cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue();

        removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]);
        removed.Scope.DeviceId.ShouldBe("Filler1");
        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
        cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
        cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse();
    }

    /// <summary>
    ///     After a death the scope is <b>absent</b>, not merely empty — that is what makes DATA arriving
    ///     before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent
    ///     unknown-alias drop against a table that still looks alive.
    /// </summary>
    [Fact]
    public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable()
    {
        var cache = new BirthCache();
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);

        cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue();

        cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
        cache.Count.ShouldBe(0);
    }

    /// <summary>
    ///     Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in
    ///     through the scoped path the ingest state machine actually calls.
    /// </summary>
    [Fact]
    public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName()
    {
        var cache = new BirthCache();
        var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1");
        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);

        cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);

        cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure");
        cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse();
    }

    /// <summary>Builds a BIRTH metric the way <see cref="SparkplugCodec"/> projects one.</summary>
    private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) =>
        new(
            Name: name,
            Alias: alias,
            DataType: dataType,
            TimestampMs: null,
            ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar,
            Value: value);

    /// <summary>Builds a malformed BIRTH metric that declares no datatype at all.</summary>
    private static SparkplugMetric Untyped(string name, ulong alias) =>
        new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null);
}
