using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Task #177 — tests covering ALMD projection detection, feature-flag gate,
///     subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAlarmProjectionTests
{
    private const string Device = "ab://10.0.0.5/1,0";

    private static AbCipTagDefinition AlmdTag(string name) => new(
        name, Device, name, AbCipDataType.Structure, Members:
        [
            new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
            new AbCipStructureMember("Acked", AbCipDataType.DInt),
            new AbCipStructureMember("Severity", AbCipDataType.DInt),
            new AbCipStructureMember("In", AbCipDataType.DInt),
        ]);

    /// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
    [Fact]
    public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
    {
        var almd = AlmdTag("HighTemp");
        AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();

        var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
            [new AbCipStructureMember("X", AbCipDataType.DInt)]);
        AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();

        var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
        AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
    }

    /// <summary>Verifies that severity values map correctly to OPC UA alarm severity levels.</summary>
    [Fact]
    public void Severity_Mapping_Matches_OPC_UA_Convention()
    {
        // Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing.
        AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
        AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
        AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
        AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
    }

    /// <summary>Verifies that disabled alarm projection returns a valid handle but does not poll.</summary>
    [Fact]
    public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
    {
        var factory = new FakeAbCipTagFactory();
        var opts = new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(Device)],
            RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
            EnableAlarmProjection = false, // explicit; also the default
        };
        var drv = new AbCipDriver(opts, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
        handle.ShouldNotBeNull();
        handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");

        // Wait a touch — if polling were active, a fake member-read would be triggered.
        await Task.Delay(100);
        factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
        factory.Tags.ShouldNotContainKey("HighTemp.Severity");

        await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
        await drv.ShutdownAsync(CancellationToken.None);
    }

    /// <summary>Verifies that enabled alarm projection starts polling and fires raise event on 0-to-1 transition.</summary>
    [Fact]
    public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
    {
        var factory = new FakeAbCipTagFactory();
        var opts = new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(Device)],
            RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
            EnableAlarmProjection = true,
            AlarmPollInterval = TimeSpan.FromMilliseconds(20),
            // The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
            // so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
            EnableDeclarationOnlyUdtGrouping = true,
        };
        var drv = new AbCipDriver(opts, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var events = new List<AlarmEventArgs>();
        drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };

        var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);

        // The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
        // gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
        // then flip to 1 (fault) + wait for the raise event.
        await WaitForTagCreation(factory, "HighTemp");
        factory.Tags["HighTemp"].ValuesByOffset[0] = 0;  // InFaulted=false at offset 0
        factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
        await Task.Delay(80); // let a tick seed the "last-seen false" state

        factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
        await Task.Delay(200); // allow several polls to be safe

        lock (events)
        {
            events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
                && e.Message.Contains("raised"));
        }

        await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
        await drv.ShutdownAsync(CancellationToken.None);
    }

    /// <summary>
    ///     Regression for the reconnect poll-loop leak (#399 sibling): the owning
    ///     DriverInstanceActor re-subscribes alarms on every Connected entry without first calling
    ///     Unsubscribe, and the driver object (and its projection) survives the in-place reconnect.
    ///     Each SubscribeAsync used to start a fresh, never-cancelled poll loop — so after N
    ///     reconnects there were N live loops all polling the device and all firing the same
    ///     raise/clear transition, producing DUPLICATE alarm events.
    ///
    ///     This simulates two re-subscribes (one reconnect) against the same source, then drives
    ///     ONE 0->1 device transition. After the collapse-to-single-loop fix exactly one loop is
    ///     alive so the raise fires exactly once; before the fix both loops fire it ⇒ two events.
    /// </summary>
    [Fact]
    public async Task Resubscribe_Collapses_To_Single_Loop_No_Duplicate_Raise()
    {
        var factory = new FakeAbCipTagFactory();
        var opts = new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(Device)],
            RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
            EnableAlarmProjection = true,
            AlarmPollInterval = TimeSpan.FromMilliseconds(20),
            EnableDeclarationOnlyUdtGrouping = true,
        };
        var drv = new AbCipDriver(opts, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var raises = new List<AlarmEventArgs>();
        drv.OnAlarmEvent += (_, e) =>
        {
            if (e.Message.Contains("raised")) lock (raises) raises.Add(e);
        };

        // First subscribe creates + starts polling the HighTemp runtime. Seed InFaulted=false +
        // a severity so the loop's baseline settles on "not faulted".
        var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
        await WaitForTagCreation(factory, "HighTemp");
        factory.Tags["HighTemp"].ValuesByOffset[0] = 0;   // InFaulted=false at offset 0
        factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8
        await Task.Delay(80); // let sub1's loop seed its "last-seen false" baseline

        // Second subscribe = the actor re-subscribing across a reconnect. Its loop reads the same
        // shared HighTemp runtime; give it time to seed its own "last-seen false" baseline too.
        var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
        await Task.Delay(80);

        factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // one 0->1 transition
        // Wait past several 20ms poll intervals so any leaked second loop has ample time to also
        // fire its duplicate raise before we assert.
        await Task.Delay(250);

        await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
        await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
        await drv.ShutdownAsync(CancellationToken.None);

        lock (raises)
        {
            raises.Count.ShouldBe(1,
                "exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
        }
    }

    /// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
    [Fact]
    public async Task Clear_Event_Fires_On_1_to_0_Transition()
    {
        var factory = new FakeAbCipTagFactory();
        var opts = new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(Device)],
            RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
            EnableAlarmProjection = true,
            AlarmPollInterval = TimeSpan.FromMilliseconds(20),
            // The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
            // so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
            EnableDeclarationOnlyUdtGrouping = true,
        };
        var drv = new AbCipDriver(opts, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var events = new List<AlarmEventArgs>();
        drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };

        var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
        await WaitForTagCreation(factory, "HighTemp");

        factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
        factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
        await Task.Delay(80); // observe raise

        factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
        await Task.Delay(200);

        lock (events)
        {
            events.ShouldContain(e => e.Message.Contains("raised"));
            events.ShouldContain(e => e.Message.Contains("cleared"));
        }

        await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
        await drv.ShutdownAsync(CancellationToken.None);
    }

    /// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
    [Fact]
    public async Task Unsubscribe_Stops_The_Poll_Loop()
    {
        var factory = new FakeAbCipTagFactory();
        var opts = new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(Device)],
            RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
            EnableAlarmProjection = true,
            AlarmPollInterval = TimeSpan.FromMilliseconds(20),
            // The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
            // so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
            EnableDeclarationOnlyUdtGrouping = true,
        };
        var drv = new AbCipDriver(opts, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
        await WaitForTagCreation(factory, "HighTemp");
        var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;

        await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
        await Task.Delay(100); // well past several poll intervals if the loop were still alive

        var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
        // Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
        (postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);

        await drv.ShutdownAsync(CancellationToken.None);
    }

    private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
    {
        var deadline = DateTime.UtcNow.AddSeconds(2);
        while (DateTime.UtcNow < deadline)
        {
            if (factory.Tags.ContainsKey(tagName)) return;
            await Task.Delay(10);
        }
        throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
    }
}
