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

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

/// <summary>
///     Unit-test coverage for <see cref="WnWrapAlarmConsumer"/>'s pure
///     parsing helpers — XML payload → <see cref="MxAlarmSnapshotRecord"/>
///     dictionary, and the 32-char-hex GUID round-trip. The COM-side
///     polling loop is verified separately by the Skip-gated
///     <c>WnWrapConsumerProbeTests</c> on a live AVEVA install.
/// </summary>
public sealed class WnWrapAlarmConsumerXmlTests
{
    /// <summary>Captured XML from the dev rig (probe run 2026-05-01).</summary>
    private const string SingleAlarmActiveXml =
        "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
        "<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
        "<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>" +
        "<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
        "<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>" +
        "<PROVIDER_NAME>Galaxy</PROVIDER_NAME>" +
        "<GROUP>TestArea</GROUP>" +
        "<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>" +
        "<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
        "<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>" +
        "<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
        "<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT></ALARM>" +
        "</ALARM_RECORDS>";

    private const string EmptyXml =
        "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";

    /// <summary>Verifies that empty XML payload returns an empty dictionary.</summary>
    [Fact]
    public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
    {
        var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
        Assert.Empty(records);
    }

    /// <summary>Verifies that null or whitespace payload returns an empty dictionary.</summary>
    [Fact]
    public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
    {
        Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
        Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml("   "));
    }

    /// <summary>Verifies that single alarm XML payload decodes the record correctly.</summary>
    [Fact]
    public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
    {
        var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);

        Assert.Single(records);
        Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
        var record = records[expectedGuid];
        Assert.Equal(expectedGuid, record.AlarmGuid);
        Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode);
        Assert.Equal("Galaxy", record.ProviderName);
        Assert.Equal("TestArea", record.Group);
        Assert.Equal("TestMachine_001.TestAlarm001", record.TagName);
        Assert.Equal("DSC", record.Type);
        Assert.Equal("true", record.Value);
        Assert.Equal("true", record.Limit);
        Assert.Equal(500, record.Priority);
        Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
        Assert.Equal("Test alarm #1", record.AlarmComment);
        Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind);
        // 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC.
        Assert.Equal(17, record.TransitionTimestampUtc.Hour);
        Assert.Equal(26, record.TransitionTimestampUtc.Minute);
    }

    /// <summary>Verifies that invalid GUIDs in XML payload are silently dropped.</summary>
    [Fact]
    public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
    {
        string xml = SingleAlarmActiveXml.Replace(
            "<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
            "<GUID>not-a-guid</GUID>");
        Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
    }

    /// <summary>Verifies that dashless 32-character hex GUIDs parse correctly.</summary>
    /// <param name="hex">The dashless hex string.</param>
    /// <param name="expected">The expected canonical GUID form.</param>
    [Theory]
    [InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
    [InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
    public void TryParseHexGuid_WithDashless32CharHex_Parses(string hex, string expected)
    {
        Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
        Assert.Equal(new Guid(expected), guid);
    }

    /// <summary>Verifies that canonical dashed GUID format is accepted.</summary>
    /// <param name="canonical">The canonical GUID form.</param>
    [Theory]
    [InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
    public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
    {
        Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
        Assert.Equal(new Guid(canonical), guid);
    }

    /// <summary>Verifies that invalid GUID inputs are rejected.</summary>
    /// <param name="hex">The invalid GUID hex string.</param>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("nope")]
    [InlineData("0123456789ABCDEF")] // too short
    [InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
    public void TryParseHexGuid_WithInvalidInput_Rejects(string? hex)
    {
        Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
        Assert.Equal(Guid.Empty, guid);
    }

    /// <summary>
    ///     The consumer must own no internal
    ///     <see cref="Timer"/>. A thread-pool timer calling the
    ///     apartment-threaded wnwrap COM object off its owning STA can
    ///     deadlock on cross-apartment marshaling, so the timer field and
    ///     callback must not exist on the type.
    /// </summary>
    [Fact]
    public void WnWrapAlarmConsumer_ByReflection_HasNoInternalTimerField()
    {
        FieldInfo[] fields = typeof(WnWrapAlarmConsumer)
            .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

        Assert.DoesNotContain(fields, field => field.FieldType == typeof(Timer));
        Assert.Null(typeof(WnWrapAlarmConsumer).GetMethod(
            "OnPoll",
            BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
    }

    /// <summary>
    ///     No public constructor may accept a
    ///     poll-interval parameter. A non-zero poll interval was the only
    ///     way to arm the off-STA timer; removing the parameter makes the
    ///     footgun structurally unreachable.
    /// </summary>
    [Fact]
    public void WnWrapAlarmConsumer_ByReflection_ExposesNoPollIntervalConstructorParameter()
    {
        foreach (ConstructorInfo constructor in typeof(WnWrapAlarmConsumer)
            .GetConstructors(BindingFlags.Instance | BindingFlags.Public))
        {
            Assert.DoesNotContain(
                constructor.GetParameters(),
                parameter => parameter.Name is not null
                    && parameter.Name.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0);
        }
    }

    /// <summary>
    ///     Pins the "new alarm sighting" branch of
    ///     <see cref="WnWrapAlarmConsumer.ComputeTransitions"/>. A GUID
    ///     that appears in <c>next</c> but not in <c>previous</c> must
    ///     produce exactly one transition with
    ///     <see cref="MxAlarmStateKind.Unspecified"/> as the previous
    ///     state — the proto layer relies on this sentinel to map a
    ///     first sighting to a <c>Raise</c>.
    /// </summary>
    [Fact]
    public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState()
    {
        Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
        Dictionary<Guid, MxAlarmSnapshotRecord> previous = new();
        Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
        {
            [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
        };

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(previous, next);

        MxAlarmTransitionEvent single = Assert.Single(transitions);
        Assert.Equal(alarmGuid, single.Record.AlarmGuid);
        Assert.Equal(MxAlarmStateKind.UnackAlm, single.Record.State);
        Assert.Equal(MxAlarmStateKind.Unspecified, single.PreviousState);
    }

    /// <summary>
    ///     Pins the "state unchanged" branch. A GUID
    ///     present in both snapshots with identical
    ///     <see cref="MxAlarmSnapshotRecord.State"/> must produce no
    ///     transition — a regression that emits a transition every poll
    ///     regardless of state change would slip through without this
    ///     test.
    /// </summary>
    [Fact]
    public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition()
    {
        Guid alarmGuid = Guid.NewGuid();
        Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
        {
            [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
        };
        Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
        {
            [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
        };

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(previous, next);

        Assert.Empty(transitions);
    }

    /// <summary>
    ///     Pins the "state changed" branch. A GUID
    ///     present in both snapshots with a different state must produce
    ///     one transition carrying the prior state so the proto layer
    ///     can distinguish e.g. <c>UnackAlm</c>→<c>AckAlm</c>
    ///     (Acknowledge) from <c>Unspecified</c>→<c>UnackAlm</c> (Raise).
    /// </summary>
    [Fact]
    public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState()
    {
        Guid alarmGuid = Guid.NewGuid();
        Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
        {
            [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
        };
        Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
        {
            [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm),
        };

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(previous, next);

        MxAlarmTransitionEvent single = Assert.Single(transitions);
        Assert.Equal(alarmGuid, single.Record.AlarmGuid);
        Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
        Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
    }

    /// <summary>
    ///     Pins the "alarm cleared from the active set"
    ///     branch. AVEVA drops cleared alarms from
    ///     <c>GetXmlCurrentAlarms2</c>'s active set rather than emitting a
    ///     transition record. A GUID present in
    ///     <c>previous</c> but absent from <c>next</c> must therefore
    ///     produce no transition; the diff treats disappearance as an
    ///     implicit clear that the proto layer recognises by the missing
    ///     GUID, not by an emitted event.
    /// </summary>
    [Fact]
    public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition()
    {
        Guid alarmGuid = Guid.NewGuid();
        Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
        {
            [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
        };
        Dictionary<Guid, MxAlarmSnapshotRecord> next = new();

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(previous, next);

        Assert.Empty(transitions);
    }

    /// <summary>
    ///     Pins the multi-alarm fan-out. Multiple
    ///     simultaneous transitions (new + changed + unchanged + dropped)
    ///     in one snapshot must produce exactly the changed and new
    ///     entries — not the unchanged and not the dropped.
    /// </summary>
    [Fact]
    public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions()
    {
        Guid newGuid = Guid.NewGuid();
        Guid changedGuid = Guid.NewGuid();
        Guid unchangedGuid = Guid.NewGuid();
        Guid droppedGuid = Guid.NewGuid();

        Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
        {
            [changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm),
            [unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
            [droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm),
        };
        Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
        {
            [newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm),
            [changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm),
            [unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
        };

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(previous, next);

        Assert.Equal(2, transitions.Count);

        MxAlarmTransitionEvent newTransition = Assert.Single(
            transitions,
            t => t.Record.AlarmGuid == newGuid);
        Assert.Equal(MxAlarmStateKind.Unspecified, newTransition.PreviousState);
        Assert.Equal(MxAlarmStateKind.UnackAlm, newTransition.Record.State);

        MxAlarmTransitionEvent changedTransition = Assert.Single(
            transitions,
            t => t.Record.AlarmGuid == changedGuid);
        Assert.Equal(MxAlarmStateKind.UnackAlm, changedTransition.PreviousState);
        Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
    }

    // -------------------------------------------------------------------------
    // Truncation cliff (Task 23). GetXmlCurrentAlarms2 caps its reply at
    // maxAlmCnt with no "more available" flag. Before the guard, a capped fetch
    // shrank the retained snapshot, and every alarm past the cap vanished from
    // SnapshotActiveAlarms — which the gateway's reconcile pass reads as a
    // Clear and broadcasts to every StreamAlarms subscriber.
    // -------------------------------------------------------------------------

    /// <summary>
    ///     A fetch that comes back holding exactly the cap must be treated as
    ///     truncated; anything below the cap must not.
    /// </summary>
    /// <param name="fetchedRecordCount">Records the reply carried.</param>
    /// <param name="maxAlarmsPerFetch">The cap passed to the fetch.</param>
    /// <param name="expected">Whether the reply must read as truncated.</param>
    [Theory]
    [InlineData(1024, 1024, true)]
    [InlineData(1023, 1024, false)]
    [InlineData(0, 1024, false)]
    [InlineData(64, 64, true)]
    public void IsTruncatedFetch_AtOrAboveCap_IsTruncated(
        int fetchedRecordCount,
        int maxAlarmsPerFetch,
        bool expected)
    {
        Assert.Equal(
            expected,
            WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch));
    }

    /// <summary>
    ///     THE truncation-correctness test. A 1024-record fetch against a
    ///     1024 cap that does not mention a known-active alarm must NOT evict
    ///     that alarm from the retained snapshot: the snapshot is what
    ///     <see cref="WnWrapAlarmConsumer.SnapshotActiveAlarms"/> returns and
    ///     what the gateway's reconcile diffs its cache against, so an
    ///     eviction here is a Clear broadcast for an alarm that never cleared.
    /// </summary>
    [Fact]
    public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch()
    {
        const int Cap = 1024;
        Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");

        string xml = BuildAlarmXml(Cap);
        Dictionary<Guid, MxAlarmSnapshotRecord> next =
            WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);

        Assert.Equal(Cap, fetchedRecordCount);
        Assert.Equal(Cap, next.Count);
        Assert.False(next.ContainsKey(missingGuid));
        Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));

        Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
        {
            [missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
        };

        // The diff itself never emits a Clear for a disappearance — the Clear
        // is the eviction, one level up. Pin both halves.
        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
        Assert.DoesNotContain(transitions, t => t.Record.AlarmGuid == missingGuid);

        WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);

        // NO Clear: the alarm the capped fetch had no room to mention survives,
        // so the gateway's reconcile still sees it active.
        Assert.True(snapshot.ContainsKey(missingGuid));
        Assert.Equal(MxAlarmStateKind.UnackAlm, snapshot[missingGuid].State);
        Assert.Equal(Cap + 1, snapshot.Count);
    }

    /// <summary>
    ///     The control case: the same missing alarm against a sub-cap fetch
    ///     must still be evicted, because a complete fetch IS authoritative
    ///     about absence. Without this, the truncation guard would have
    ///     silently disabled clears altogether.
    /// </summary>
    [Fact]
    public void ApplySnapshotUpdate_WhenFetchBelowCap_EvictsAlarmMissingFromFetch()
    {
        const int Cap = 1024;
        Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");

        string xml = BuildAlarmXml(Cap - 1);
        Dictionary<Guid, MxAlarmSnapshotRecord> next =
            WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);

        Assert.Equal(Cap - 1, fetchedRecordCount);
        Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));

        Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
        {
            [missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
        };

        WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: false);

        // Clear as today: the alarm drops out of the snapshot, and the
        // gateway's reconcile turns that absence into a Clear.
        Assert.False(snapshot.ContainsKey(missingGuid));
        Assert.Equal(Cap - 1, snapshot.Count);
    }

    /// <summary>
    ///     A truncated fetch must still apply the alarms it DID carry — the
    ///     guard suppresses eviction, not the update. An alarm whose state
    ///     changed inside a capped reply still transitions.
    /// </summary>
    [Fact]
    public void ApplySnapshotUpdate_WhenFetchTruncated_StillAppliesPresentAlarms()
    {
        Guid presentGuid = new Guid("22222222-2222-2222-2222-222222222222");
        Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");

        Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
        {
            [presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm),
            [missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
        };
        Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
        {
            [presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm),
        };

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);

        MxAlarmTransitionEvent single = Assert.Single(transitions);
        Assert.Equal(presentGuid, single.Record.AlarmGuid);
        Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
        Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);

        WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);

        Assert.Equal(MxAlarmStateKind.AckAlm, snapshot[presentGuid].State);
        Assert.True(snapshot.ContainsKey(missingGuid));
    }

    /// <summary>
    ///     Truncation is detected from the raw ALARM-element count, not the
    ///     parsed dictionary size. A record dropped for a malformed GUID still
    ///     consumed a slot in the capped reply, so counting only survivors
    ///     would let a truncated fetch pass as complete and re-open the cliff.
    /// </summary>
    [Fact]
    public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid()
    {
        const int Cap = 64;
        string xml = BuildAlarmXml(Cap).Replace(
            "<GUID>00000000000000000000000000000001</GUID>",
            "<GUID>not-a-guid</GUID>");

        Dictionary<Guid, MxAlarmSnapshotRecord> records =
            WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);

        Assert.Equal(Cap, fetchedRecordCount);
        Assert.Equal(Cap - 1, records.Count);
        Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
    }

    /// <summary>
    ///     The per-fetch cap comes from the launcher-set environment variable;
    ///     a missing, unparseable, or below-floor value must fall back to the
    ///     1024 default rather than throw. A bad environment value must never
    ///     stop the alarm consumer from starting.
    /// </summary>
    /// <param name="environmentValue">Raw environment value under test.</param>
    /// <param name="expected">Expected resolved cap.</param>
    [Theory]
    [InlineData(null, 1024)]
    [InlineData("", 1024)]
    [InlineData("not-a-number", 1024)]
    [InlineData("0", 1024)]
    [InlineData("-5", 1024)]
    [InlineData("63", 1024)]
    [InlineData("64", 64)]
    [InlineData("4096", 4096)]
    [InlineData("65536", 65536)]
    // Above the ceiling: the x86 worker materializes the whole reply as one
    // BSTR plus an XmlDocument, so an unbounded cap is an OOM on the STA.
    [InlineData("65537", 1024)]
    [InlineData("2147483647", 1024)]
    public void ResolveMaxAlarmsPerFetch_WithEnvironmentValue_FallsBackToDefaultWhenUnusable(
        string? environmentValue,
        int expected)
    {
        string? original = Environment.GetEnvironmentVariable(
            WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName);
        try
        {
            Environment.SetEnvironmentVariable(
                WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
                environmentValue);

            Assert.Equal(expected, WnWrapAlarmConsumer.ResolveMaxAlarmsPerFetch());
        }
        finally
        {
            Environment.SetEnvironmentVariable(
                WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
                original);
        }
    }

    /// <summary>
    ///     A galaxy parked above the cap truncates on every poll, so the
    ///     warning must be throttled: two truncated polls inside one interval
    ///     produce exactly one line, and the next one only after the full
    ///     interval has elapsed.
    /// </summary>
    [Fact]
    public void ShouldWarnTruncation_ThrottlesConsecutiveTruncatedPollsToOneWarningPerInterval()
    {
        // Seeded so the very first truncated poll always warns.
        const long NeverWarned = -60_000;

        // Poll 1 at t=0: warns, and records t=0 as the last warning.
        Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(0, NeverWarned));

        // Poll 2 half a second later (the default cadence): suppressed.
        Assert.False(WnWrapAlarmConsumer.ShouldWarnTruncation(500, 0));

        // Still suppressed just shy of the interval...
        Assert.False(WnWrapAlarmConsumer.ShouldWarnTruncation(59_999, 0));

        // ...and allowed again exactly on it.
        Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(60_000, 0));
        Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(120_000, 60_000));
    }

    /// <summary>
    ///     Builds a well-formed ALARM_RECORDS payload with
    ///     <paramref name="count"/> distinct alarms. GUIDs are the dashless
    ///     32-char hex form wnwrap actually emits, numbered from 1 so a test
    ///     can name one (e.g. ...0001) to corrupt.
    /// </summary>
    /// <param name="count">Number of ALARM elements to emit.</param>
    /// <returns>The XML payload.</returns>
    private static string BuildAlarmXml(int count)
    {
        System.Text.StringBuilder sb = new System.Text.StringBuilder();
        sb.Append("<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"")
          .Append(count)
          .Append("\">");
        for (int index = 1; index <= count; index++)
        {
            sb.Append("<ALARM><GUID>")
              .Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture))
              .Append("</GUID>")
              .Append("<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>")
              .Append("<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>")
              .Append("<PROVIDER_NODE>TEST-NODE</PROVIDER_NODE>")
              .Append("<PROVIDER_NAME>Galaxy</PROVIDER_NAME>")
              .Append("<GROUP>TestArea</GROUP>")
              .Append("<TAGNAME>TestMachine_")
              .Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture))
              .Append(".TestAlarm</TAGNAME>")
              .Append("<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>")
              .Append("<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>")
              .Append("<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>")
              .Append("<ALARM_COMMENT>Test alarm</ALARM_COMMENT></ALARM>");
        }
        sb.Append("</ALARM_RECORDS>");
        return sb.ToString();
    }

    // -------------------------------------------------------------------------
    // Single-pass parse parity (Task 23). The per-field SelectSingleNode calls
    // were replaced by one walk over ChildNodes; these pin the semantics that
    // walk has to reproduce.
    // -------------------------------------------------------------------------

    /// <summary>
    ///     An absent child element and a present-but-empty one must both yield
    ///     <see cref="string.Empty"/>, exactly as
    ///     <c>SelectSingleNode(name)?.InnerText ?? string.Empty</c> did.
    /// </summary>
    [Fact]
    public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings()
    {
        string xml =
            "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
            "<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
            "<TAGNAME></TAGNAME>" +
            "<STATE>UNACK_ALM</STATE></ALARM>" +
            "</ALARM_RECORDS>";

        Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
        MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];

        Assert.Equal(string.Empty, record.TagName);        // present but empty
        Assert.Equal(string.Empty, record.ProviderNode);   // absent
        Assert.Equal(string.Empty, record.Value);          // absent
        Assert.Equal(string.Empty, record.AlarmComment);   // absent
        Assert.Equal(0, record.Priority);                  // absent → ParseInt("") → 0
        Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
    }

    /// <summary>
    ///     Duplicate child elements must resolve to the FIRST occurrence —
    ///     <c>SelectSingleNode</c> returned the first match, so a last-wins
    ///     walk would silently change which value a malformed payload yields.
    /// </summary>
    [Fact]
    public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence()
    {
        string xml =
            "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
            "<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
            "<TAGNAME>First</TAGNAME><TAGNAME>Second</TAGNAME>" +
            "<PRIORITY>100</PRIORITY><PRIORITY>900</PRIORITY>" +
            "<STATE>UNACK_ALM</STATE></ALARM>" +
            "</ALARM_RECORDS>";

        Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
        MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];

        Assert.Equal("First", record.TagName);
        Assert.Equal(100, record.Priority);
    }

    /// <summary>
    ///     Field names are matched ordinally and case-sensitively, as the
    ///     XPath node test was. A lowercase element must not populate the
    ///     field its uppercase counterpart owns.
    /// </summary>
    [Fact]
    public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField()
    {
        string xml =
            "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
            "<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
            "<tagname>ShouldBeIgnored</tagname>" +
            "<STATE>UNACK_ALM</STATE></ALARM>" +
            "</ALARM_RECORDS>";

        Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
        MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];

        Assert.Equal(string.Empty, record.TagName);
    }

    /// <summary>
    ///     Non-element children (whitespace text, comments, CDATA) must be
    ///     skipped rather than matched by name, and a nested element must
    ///     contribute its InnerText exactly as <c>InnerText</c> always did.
    /// </summary>
    [Fact]
    public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren()
    {
        string xml =
            "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">\n" +
            "  <ALARM>\n" +
            "    <!-- a comment -->\n" +
            "    <GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>\n" +
            "    <TAGNAME>TestMachine.TestAlarm</TAGNAME>\n" +
            "    <STATE>UNACK_ALM</STATE>\n" +
            "  </ALARM>\n" +
            "</ALARM_RECORDS>";

        Dictionary<Guid, MxAlarmSnapshotRecord> records =
            WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);

        Assert.Equal(1, fetchedRecordCount);
        MxAlarmSnapshotRecord record = Assert.Single(records).Value;
        Assert.Equal("TestMachine.TestAlarm", record.TagName);
        Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
    }

    // -------------------------------------------------------------------------
    // Degraded-status signal. The truncation guard above keeps a capped fetch
    // from broadcasting phantom Clears, but it does so silently: the retained
    // snapshot simply stops shrinking. The truncation verdict SnapshotActiveAlarms
    // hands back alongside the records is what makes that suppression visible to
    // the QueryActiveAlarms reply and, through it, the dashboard banner — so its
    // set/reset behaviour is the contract, not detail.
    //
    // These assert through SnapshotActiveAlarms(out ...) rather than any internal
    // field, because the pairing IS the contract: records and verdict must come
    // out of one call, produced under one lock acquisition.
    // -------------------------------------------------------------------------

    /// <summary>
    ///     A capped fetch sets the truncation verdict handed out with the
    ///     snapshot. Without this the signal never leaves the consumer and the
    ///     reply builder stamps a complete-looking snapshot over a capped one.
    /// </summary>
    [Fact]
    public void SnapshotActiveAlarms_AfterTruncatedFetch_ReportsTruncated()
    {
        const int Cap = 8;
        using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);

        consumer.SnapshotActiveAlarms(out bool truncatedBeforeAnyFetch);
        Assert.False(truncatedBeforeAnyFetch);

        Dictionary<Guid, MxAlarmSnapshotRecord> next =
            WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount);
        Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));

        consumer.FoldFetch(next, truncated: true, out int retainedCount);

        IReadOnlyList<MxAlarmSnapshotRecord> snapshot =
            consumer.SnapshotActiveAlarms(out bool truncated);

        Assert.True(truncated);
        Assert.Equal(Cap, retainedCount);
        // The verdict describes THIS set — assert they arrive together, not just
        // that the boolean flipped somewhere.
        Assert.Equal(Cap, snapshot.Count);
    }

    /// <summary>
    ///     THE reset test. A sub-cap fetch is complete, so it restores absence
    ///     authority and must clear the verdict. Latching it instead would leave
    ///     the operator banner asserting "snapshot may be incomplete" forever
    ///     after a single burst above the cap, which trains operators to ignore
    ///     it — the opposite of what the signal is for.
    /// </summary>
    [Fact]
    public void SnapshotActiveAlarms_AfterSubCapFetchFollowingTruncation_ReportsComplete()
    {
        const int Cap = 8;
        using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);

        Dictionary<Guid, MxAlarmSnapshotRecord> capped =
            WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
        consumer.FoldFetch(capped, truncated: true, out _);
        consumer.SnapshotActiveAlarms(out bool truncatedAfterCappedFetch);
        Assert.True(truncatedAfterCappedFetch);

        Dictionary<Guid, MxAlarmSnapshotRecord> complete =
            WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount);
        Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));

        consumer.FoldFetch(complete, truncated: false, out int retainedCount);

        IReadOnlyList<MxAlarmSnapshotRecord> snapshot =
            consumer.SnapshotActiveAlarms(out bool truncated);

        Assert.False(truncated);
        // The complete fetch also replaced the snapshot wholesale, which is what
        // makes it authoritative about absence — pinned here so a future change
        // cannot clear the verdict while keeping the merge semantics.
        Assert.Equal(Cap - 1, retainedCount);
        Assert.Equal(Cap - 1, snapshot.Count);
    }

    /// <summary>
    ///     Consecutive capped fetches keep the verdict set. It is per-fetch state,
    ///     not an edge-triggered one-shot: an operator arriving mid-burst must
    ///     still see the caveat.
    /// </summary>
    [Fact]
    public void SnapshotActiveAlarms_WithConsecutiveTruncatedFetches_KeepsReportingTruncated()
    {
        const int Cap = 8;
        using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);

        for (int pass = 0; pass < 3; pass++)
        {
            Dictionary<Guid, MxAlarmSnapshotRecord> capped =
                WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
            consumer.FoldFetch(capped, truncated: true, out _);

            consumer.SnapshotActiveAlarms(out bool truncated);
            Assert.True(truncated);
        }
    }

    private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
    {
        return new MxAlarmSnapshotRecord
        {
            AlarmGuid = guid,
            State = state,
            TagName = "TestMachine.TestAlarm",
            ProviderNode = "TEST-NODE",
            ProviderName = "Galaxy",
        };
    }
}
