using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;

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

/// <summary>
///     Covers <see cref="RebirthRequester"/> (Task 20) — the NCMD encode + bounded publish seam that
///     Task 21 (ingest gap/unknown-alias/data-before-birth detection) and Task 23 (the browser's
///     operator-triggered scoped rebirth) both call into.
/// </summary>
public sealed class RebirthRequesterTests
{
    // -----------------------------------------------------------------------------------------
    // Build — pure encode. The plan's own test, verbatim.
    // -----------------------------------------------------------------------------------------

    [Fact]
    public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
    {
        var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");

        topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
        var p = SparkplugCodec.Decode(bytes);
        p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
    }

    /// <summary>
    ///     Falsifiability control for the test above: a metric named anything else must NOT satisfy the
    ///     assertion — proves the <c>ShouldContain</c> predicate is actually discriminating on the name,
    ///     not vacuously true because <see cref="SparkplugCodec.Decode"/> always returns something.
    /// </summary>
    [Fact]
    public void BuildRebirthNcmd_DoesNotContainAWrongMetricName()
    {
        var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
        var p = SparkplugCodec.Decode(bytes);

        p.Metrics.ShouldNotContain(m => m.Name == "Node Control/NotRebirth" && Equals(m.Value, true));
    }

    /// <summary>
    ///     Falsifiability control: a boolean <c>false</c> must not satisfy the plan's assertion either —
    ///     proves the test is checking the metric's <i>value</i>, not merely its presence.
    /// </summary>
    [Fact]
    public void BuildRebirthNcmd_ValueIsTrue_NotFalse()
    {
        var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
        var p = SparkplugCodec.Decode(bytes);

        p.Metrics.ShouldNotContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, false));
    }

    [Fact]
    public void Build_DifferentGroupAndNode_ProducesMatchingTopic()
    {
        var (topic, _) = RebirthRequester.Build("Line7", "Filler42");
        topic.ShouldBe("spBv1.0/Line7/NCMD/Filler42");
    }

    [Theory]
    [InlineData(null, "EdgeA")]
    [InlineData("", "EdgeA")]
    [InlineData("Plant1", null)]
    [InlineData("Plant1", "")]
    public void Build_MissingGroupOrNode_Throws(string? groupId, string? edgeNodeId)
        => Should.Throw<ArgumentException>(() => RebirthRequester.Build(groupId!, edgeNodeId!));

    // -----------------------------------------------------------------------------------------
    // Encoded metric shape — datatype + no bogus seq
    // -----------------------------------------------------------------------------------------

    [Fact]
    public void Build_MetricCarriesBooleanDatatype()
    {
        var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
        var p = SparkplugCodec.Decode(bytes);

        var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName);
        metric.DataType.ShouldBe(TahuDataType.Boolean);
    }

    /// <summary>
    ///     A host-issued NCMD is not part of the edge node's own outbound sequence stream (Sparkplug
    ///     §6.4.1) — stamping a <c>seq</c> here would be meaningless at best. See the design rationale
    ///     on <see cref="RebirthRequester"/>.
    /// </summary>
    [Fact]
    public void Build_PayloadCarriesNoSeq()
    {
        var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
        var p = SparkplugCodec.Decode(bytes);

        p.Seq.ShouldBeNull();
    }

    [Fact]
    public void Build_PayloadAndMetricCarryATimestamp()
    {
        var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
        var p = SparkplugCodec.Decode(bytes);

        p.TimestampMs.ShouldNotBeNull();
        var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName);
        metric.TimestampMs.ShouldNotBeNull();
    }

    // -----------------------------------------------------------------------------------------
    // RequestAsync — the bounded publish seam
    // -----------------------------------------------------------------------------------------

    private sealed class RecordingTransport : IMqttPublishTransport
    {
        public int PublishCount { get; private set; }
        public string? LastTopic { get; private set; }
        public byte[]? LastPayload { get; private set; }
        public int LastQos { get; private set; } = -1;
        public bool LastRetain { get; private set; }

        public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
        {
            PublishCount++;
            LastTopic = topic;
            LastPayload = payload;
            LastQos = qos;
            LastRetain = retain;
            return Task.CompletedTask;
        }
    }

    [Fact]
    public async Task RequestAsync_PublishesExactlyOnce_WithBuiltTopicAndBytes()
    {
        var transport = new RecordingTransport();

        await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);

        transport.PublishCount.ShouldBe(1);
        transport.LastTopic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
        var decoded = SparkplugCodec.Decode(transport.LastPayload!);
        decoded.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
    }

    /// <summary>
    ///     Retain MUST be false — a retained NCMD replays to the edge node on every future
    ///     subscribe/reconnect, driving a permanent rebirth loop from one stale command (see the
    ///     carry-forward landmine remarks on <see cref="RebirthRequester"/>).
    /// </summary>
    [Fact]
    public async Task RequestAsync_NeverRetains()
    {
        var transport = new RecordingTransport();
        await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);

        transport.LastRetain.ShouldBeFalse();
    }

    [Fact]
    public async Task RequestAsync_UsesQos0()
    {
        var transport = new RecordingTransport();
        await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);

        transport.LastQos.ShouldBe(0);
    }

    [Fact]
    public async Task RequestAsync_NullTransport_Throws()
        => await Should.ThrowAsync<ArgumentNullException>(
            () => RebirthRequester.RequestAsync(null!, "Plant1", "EdgeA", TestContext.Current.CancellationToken));

    /// <summary>
    ///     A transport that hangs forever must not be able to park the caller — the cross-cutting rule
    ///     every network call in this driver follows (mirrors <c>MqttConnection.SubscribeAsync</c>).
    /// </summary>
    private sealed class HangingTransport : IMqttPublishTransport
    {
        public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
            => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
    }

    [Fact]
    public async Task RequestAsync_BoundedByDeadline_ThrowsRatherThanHangs()
    {
        var transport = new HangingTransport();

        await Should.ThrowAsync<OperationCanceledException>(() => RebirthRequester.RequestAsync(
            transport,
            "Plant1",
            "EdgeA",
            TestContext.Current.CancellationToken,
            timeout: TimeSpan.FromMilliseconds(50)));
    }

    [Fact]
    public async Task RequestAsync_CallerCancellation_Propagates()
    {
        var transport = new HangingTransport();
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(
            () => RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", cts.Token));
    }
}
