using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml;
using WNWRAPCONSUMERLib;

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

/// <summary>
///     Production <see cref="IMxAccessAlarmConsumer"/> backed by AVEVA's
///     standalone <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> COM object
///     (CLSID <c>{7AB52E5F-36B2-4A30-AE46-952A746F667C}</c>, hosted by
///     <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>).
/// </summary>
/// <remarks>
///     <para>
///         Replaces the earlier <c>AlarmClientConsumer</c> built on
///         <c>aaAlarmManagedClient.AlarmClient</c>, which crashed in
///         <c>GetHighPriAlarm</c> with <c>ArgumentOutOfRangeException</c>
///         (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps).
///         The wnwrap surface returns the alarm record as a BSTR XML string
///         via <c>GetXmlCurrentAlarms2</c>; timestamps arrive as ASCII
///         <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
///         fields and never touch the .NET DateTime marshaler. See
///         <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" for
///         the discovery and the captured payload schema.
///     </para>
///     <para>
///         <strong>Threading.</strong> The wnwrap CLSID is registered with
///         <c>ThreadingModel=Apartment</c>. The consumer must be created
///         and operated from an STA thread; the worker's
///         <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
///         The consumer owns <em>no</em> internal timer: every COM call
///         (<c>Subscribe</c>, <c>PollOnce</c>, <c>AcknowledgeBy*</c>) must
///         be invoked on the STA that created the consumer. Polling cadence
///         is driven externally by the worker's STA via
///         <c>StaRuntime.InvokeAsync(() =&gt; consumer.PollOnce())</c>, which
///         keeps every <c>GetXmlCurrentAlarms2</c> call on the apartment that
///         owns the COM object. A thread-pool timer would call the COM API
///         off the owning STA and can deadlock on cross-apartment marshaling
///         when the STA is not pumping messages, so no such timer exists.
///     </para>
/// </remarks>
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
{
    private const string DefaultProductName = "OtOpcUa.MxGateway";
    private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
    private const string DefaultVersion = "1.0";
    private const int DefaultMaxAlarmsPerFetch = 1024;

    /// <summary>
    ///     Floor on the resolved per-fetch cap. Mirrors the gateway-side
    ///     <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> minimum so a value that
    ///     slipped past startup validation still leaves a usable snapshot
    ///     window.
    /// </summary>
    internal const int MinimumMaxAlarmsPerFetch = 64;

    /// <summary>
    ///     Ceiling on the resolved per-fetch cap. Mirrors the gateway-side
    ///     <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> maximum. The worker is a
    ///     32-bit process: every poll materializes the whole reply as one BSTR
    ///     and then a full <see cref="XmlDocument"/> over it, so an unbounded
    ///     cap (<see cref="int.MaxValue"/> passed startup validation before
    ///     this existed) is an out-of-memory fault on the STA, not a slow poll.
    /// </summary>
    internal const int MaximumMaxAlarmsPerFetch = 65_536;

    /// <summary>
    ///     Environment variable the gateway's <c>WorkerProcessLauncher</c>
    ///     sets from <c>MxGateway:Alarms:MaxAlarmsPerFetch</c>. A missing,
    ///     unparseable, or out-of-range value falls back to
    ///     <see cref="DefaultMaxAlarmsPerFetch"/> — a bad environment value
    ///     must never keep the alarm consumer from starting.
    /// </summary>
    internal const string MaxAlarmsPerFetchEnvironmentVariableName =
        "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";

    /// <summary>
    ///     Minimum gap between truncated-fetch warnings. A galaxy parked above
    ///     the cap truncates on *every* poll, so an unthrottled warning would
    ///     be two log lines a second forever.
    /// </summary>
    private const long TruncationWarningIntervalMilliseconds = 60_000;

    private readonly object syncRoot = new object();
    private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
        new Dictionary<Guid, MxAlarmSnapshotRecord>();
    private readonly int maxAlarmsPerFetch;
    private readonly Stopwatch truncationWarningClock = Stopwatch.StartNew();

    private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
    private long truncatedFetchCount;
    private bool lastSnapshotTruncated;
    private wwAlarmConsumerClass? client;
    private wwAlarmConsumerClass? ackClient;
    private bool subscribed;
    private bool disposed;

    /// <summary>
    ///     Production constructor — creates the wnwrap COM object on the
    ///     current thread (which must be the worker's STA). Polling is driven
    ///     externally by the STA via
    ///     <c>StaRuntime.InvokeAsync(() =&gt; consumer.PollOnce())</c> so that
    ///     every COM call stays on the STA that owns the apartment.
    /// </summary>
    public WnWrapAlarmConsumer()
        : this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch())
    {
    }

    /// <summary>
    ///     Test seam / explicit construction.
    /// </summary>
    /// <param name="client">The COM alarm consumer instance.</param>
    /// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
    public WnWrapAlarmConsumer(
        wwAlarmConsumerClass client,
        int maxAlarmsPerFetch)
    {
        this.client = client ?? throw new ArgumentNullException(nameof(client));
        this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
            ? maxAlarmsPerFetch
            : DefaultMaxAlarmsPerFetch;
    }

    /// <summary>
    ///     COM-free construction, for exercising the retained-snapshot state
    ///     machine (<see cref="FoldFetch"/> / <see cref="SnapshotActiveAlarms"/>)
    ///     on a machine without AVEVA
    ///     installed. <see cref="Subscribe"/> throws and <see cref="PollOnce"/>
    ///     no-ops on an instance built this way — both need the wnwrap coclass,
    ///     which cannot be instantiated on the macOS/Linux test matrix. Internal
    ///     rather than public so it cannot be reached from production wiring.
    /// </summary>
    /// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
    internal WnWrapAlarmConsumer(int maxAlarmsPerFetch)
    {
        this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
            ? maxAlarmsPerFetch
            : DefaultMaxAlarmsPerFetch;
    }

    /// <summary>
    ///     Resolves the per-fetch cap from the launcher-provided environment
    ///     variable. A missing, unparseable, or out-of-range value falls back
    ///     to <see cref="DefaultMaxAlarmsPerFetch"/> rather than throwing:
    ///     polling at the default cap is always safe, and a session that
    ///     refuses to subscribe over a mistyped environment variable is not.
    ///     The ceiling is enforced here as well as in the gateway validator so
    ///     a hand-set environment (or a future launcher bug) cannot hand the
    ///     x86 worker a cap that faults it on the first poll.
    /// </summary>
    /// <returns>The cap passed to <c>GetXmlCurrentAlarms2</c>.</returns>
    internal static int ResolveMaxAlarmsPerFetch()
    {
        string? value = Environment.GetEnvironmentVariable(MaxAlarmsPerFetchEnvironmentVariableName);
        return int.TryParse(
                value,
                NumberStyles.Integer,
                CultureInfo.InvariantCulture,
                out int cap)
            && cap >= MinimumMaxAlarmsPerFetch
            && cap <= MaximumMaxAlarmsPerFetch
                ? cap
                : DefaultMaxAlarmsPerFetch;
    }

    /// <summary>
    ///     Fires once per detected alarm-state transition (raise, acknowledge,
    ///     clear, or new-alarm-already-acked-on-arrival), dispatched from
    ///     <see cref="PollOnce"/> on the calling (STA) thread.
    /// </summary>
    public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;

    /// <inheritdoc />
    public void Subscribe(string subscription)
    {
        if (subscription is null) throw new ArgumentNullException(nameof(subscription));
        if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));

        lock (syncRoot)
        {
            if (subscribed)
            {
                throw new InvalidOperationException(
                    "WnWrapAlarmConsumer.Subscribe was called more than once; " +
                    "wwAlarmConsumerClass.Subscribe replaces the previous filter and is not idempotent.");
            }

            wwAlarmConsumerClass com = client
                ?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));

            // Use the IwwAlarmConsumer (v1) prefix-named methods for the
            // lifecycle. Empirically (live dev-rig 2026-05-01, reconfirmed
            // 2026-08-18) this is the only path that lets AlarmAckByName
            // return rc=0 afterwards. The v2 Initialize/Register/Subscribe
            // methods on the class succeed (return 0) but acks against that
            // consumer state return -55. Note rc=0 means the call was
            // accepted, not that an acknowledgement was applied — see
            // AcknowledgeByName below and docs/AlarmProbeFindings.md.
            int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
            if (init != 0)
            {
                throw new InvalidOperationException(
                    $"wwAlarmConsumer.InitializeConsumer returned non-zero status {init}.");
            }

            // hWnd=0: wnwrap supports a pull-based model — no message pump
            // is required. GetXmlCurrentAlarms2 is polled by the worker's STA
            // via StaRuntime.InvokeAsync(() => consumer.PollOnce()); this type
            // owns no internal timer.
            int reg = com.IwwAlarmConsumer_RegisterConsumer(
                hWnd: 0,
                szProductName: DefaultProductName,
                szApplicationName: DefaultApplicationName,
                szVersion: DefaultVersion);
            if (reg != 0)
            {
                throw new InvalidOperationException(
                    $"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
            }

            int sub = com.IwwAlarmConsumer_Subscribe(
                szSubscription: subscription,
                wFromPri: 1,
                wToPri: 999,
                QueryType: eQueryType.qtSummary,
                SortFlags: eSortFlags.sfReturnNewestFirst,
                FilterMask: eAlarmFilterState.asAlarmActiveNow,
                FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
            if (sub != 0)
            {
                throw new InvalidOperationException(
                    $"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
            }

            // Empirically required: even though the round-trip echo of
            // SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
            // calling it is necessary for subsequent GetXmlCurrentAlarms2
            // calls to succeed. Without it, GetXmlCurrentAlarms2 returns
            // E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
            // also breaks AlarmAckByName on the same consumer (rejects with
            // -55), so a separate ack-only consumer is provisioned below
            // that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
            //
            // The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
            // — there is no integer return code to gate on like the other v1
            // lifecycle calls in this method. A genuine failure surfaces as a
            // COM exception (mapped from the underlying HRESULT). Wrap the
            // call so a failure becomes an InvalidOperationException with
            // diagnostic context, matching the other call-gates' failure
            // shape rather than letting an opaque COMException escape with
            // no indication that the alarm subscription is now misconfigured
            // and the next GetXmlCurrentAlarms2 poll will fail with E_FAIL.
            string xmlQuery = ComposeXmlAlarmQuery(subscription);
            try
            {
                com.SetXmlAlarmQuery(xmlQuery);
            }
            catch (COMException ex)
            {
                throw new InvalidOperationException(
                    $"wwAlarmConsumer.SetXmlAlarmQuery failed with HRESULT 0x{ex.HResult:X8}; " +
                    "subsequent GetXmlCurrentAlarms2 polls would return E_FAIL.",
                    ex);
            }

            // Provision a parallel COM consumer for ack calls. It runs the
            // v1 lifecycle (Initialize/Register/Subscribe) only; without
            // SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
            // — we never poll this consumer.
            ackClient = new wwAlarmConsumerClass();
            int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
            int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
                hWnd: 0,
                szProductName: DefaultProductName,
                szApplicationName: DefaultApplicationName + ".ack",
                szVersion: DefaultVersion);
            int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
                szSubscription: subscription,
                wFromPri: 1,
                wToPri: 999,
                QueryType: eQueryType.qtSummary,
                SortFlags: eSortFlags.sfReturnNewestFirst,
                FilterMask: eAlarmFilterState.asAlarmActiveNow,
                FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
            if (ackInit != 0 || ackReg != 0 || ackSub != 0)
            {
                throw new InvalidOperationException(
                    $"Ack consumer setup returned non-zero status: " +
                    $"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
            }

            subscribed = true;
        }
    }

    /// <inheritdoc />
    public int AcknowledgeByGuid(
        Guid alarmGuid,
        string ackComment,
        string ackOperatorName,
        string ackOperatorNode,
        string ackOperatorDomain,
        string ackOperatorFullName)
    {
        if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));

        wwAlarmConsumerClass com = client
            ?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));

        // VBGUID is wnwrap's GUID interop struct (same memory layout as
        // System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
        // unmanaged-blittable round-trip.
        VBGUID vb = ToVbGuid(alarmGuid);
        return com.AlarmAckByGUID(
            AlmGUID: vb,
            szComment: ackComment ?? string.Empty,
            szOprName: ackOperatorName ?? string.Empty,
            szNode: ackOperatorNode ?? string.Empty,
            szDomainName: ackOperatorDomain ?? string.Empty,
            szOprFullName: ackOperatorFullName ?? string.Empty);
    }

    /// <inheritdoc />
    public int AcknowledgeByName(
        string alarmName,
        string providerName,
        string groupName,
        string ackComment,
        string ackOperatorName,
        string ackOperatorNode,
        string ackOperatorDomain,
        string ackOperatorFullName)
    {
        if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));

        // Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
        // — see docs/AlarmClientDiscovery.md "Option A — captured" for the
        // empirical justification.
        wwAlarmConsumerClass com = ackClient
            ?? throw new InvalidOperationException(
                "Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");

        // The original observation (live dev-rig 2026-05-01) was narrower than
        // it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55
        // "and looked like a stub" while the legacy 6-arg
        // IwwAlarmConsumer.AlarmAckByName returned 0, which is how the 6-arg
        // overload came to be the one called here. The 2026-08-18 probe
        // corrected both halves. The -55 tracks the *consumer*, not the
        // overload: both overloads return -55 against a
        // SetXmlAlarmQuery-applied consumer and 0 against the ack-only one
        // used above. And a zero return is NOT evidence the acknowledgement
        // was applied — the probe acked a real, freshly raised alarm six ways
        // and watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty,
        // and the alarm extension's own .Acked attribute stay False for 16 s
        // after each call. On that rig the ack is accepted and then inert, so
        // whether any wnwrap ack reaches the alarm-history path is
        // unverified; see docs/AlarmProbeFindings.md and the 2026-08-18
        // correction in docs/AlarmClientDiscovery.md. The 6-arg call site
        // below therefore stays as-is for MXAccess parity — the choice is no
        // longer justified by the rc, and rc semantics are per the probe.
        //
        // Operator-domain and operator-full-name fields are accepted by the
        // proto contract for forward-compat but are not propagated to AVEVA
        // today — wrapped in the 6-arg call so domain/full-name go to the
        // alarm-history operator-name field via the szOprName parameter.
        // Suppress unused-warning explicitly:
        _ = ackOperatorDomain;
        _ = ackOperatorFullName;
        return com.AlarmAckByName(
            szAlarmName: alarmName ?? string.Empty,
            szProviderName: providerName ?? string.Empty,
            szGroupName: groupName ?? string.Empty,
            szComment: ackComment ?? string.Empty,
            szOprName: ackOperatorName ?? string.Empty,
            szNode: ackOperatorNode ?? string.Empty);
    }

    /// <inheritdoc />
    /// <remarks>
    ///     This is the set the gateway's reconcile pass diffs its cache
    ///     against, so an alarm missing here is what the gateway reads as a
    ///     Clear. After a truncated fetch the snapshot deliberately still
    ///     carries alarms the capped reply could not mention — see
    ///     <see cref="PollOnce"/>. Erring toward "still active" keeps the feed
    ///     at-least-once: a stale entry is repaired by the next sub-cap poll,
    ///     whereas a dropped one is broadcast as a Clear that never happened.
    ///     The snapshot and <paramref name="truncated"/> are produced under one
    ///     <c>syncRoot</c> acquisition, the same one <see cref="FoldFetch"/>
    ///     writes them both under, so no poll can interleave between them.
    /// </remarks>
    public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
    {
        if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
        lock (syncRoot)
        {
            List<MxAlarmSnapshotRecord> active = new List<MxAlarmSnapshotRecord>();
            foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values)
            {
                if (record.State == MxAlarmStateKind.UnackAlm
                    || record.State == MxAlarmStateKind.AckAlm)
                {
                    active.Add(record);
                }
            }
            truncated = lastSnapshotTruncated;
            return active;
        }
    }

    /// <summary>
    ///     Sink for the rate-limited truncated-fetch warning. Defaults to
    ///     <see cref="Console.Error"/>, the stream
    ///     <c>WorkerConsoleLogger</c> already writes to, so the line lands in
    ///     the worker's captured stderr alongside the rest of its log. Tests
    ///     substitute a collector. The message carries identifiers and counts
    ///     only — never alarm values, tag names, or comments.
    /// </summary>
    internal Action<string>? TruncationWarningSink { get; set; }

    /// <inheritdoc />
    public void PollOnce()
    {
        wwAlarmConsumerClass? com;
        lock (syncRoot)
        {
            if (disposed || !subscribed) return;
            com = client;
        }
        if (com is null) return;

        object xmlObj = string.Empty;
        com.GetXmlCurrentAlarms2(maxAlmCnt: maxAlarmsPerFetch, vartCurrentXmlAlarms: out xmlObj);
        string xml = xmlObj?.ToString() ?? string.Empty;
        if (xml.Length == 0) return;

        Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);

        // TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
        // and gives no *verified* "there is more" flag, so a reply holding
        // exactly the cap is indistinguishable from a galaxy that happens to
        // have exactly that many active alarms. Treat the ambiguous case as
        // truncated: the false-positive cost is a snapshot that stays stale for
        // one poll, the false-negative cost is every alarm past the cap reading
        // as cleared. (The reply's ALARM_RECORDS/@COUNT attribute was the
        // candidate exact signal; the 2026-08-18 live probe settled it — with
        // three alarms active, @COUNT read 3, 1, and 2 at caps 1024, 1, and 2,
        // matching the records in the reply every time. It reports the reply,
        // not the total, so it cannot tell a capped fetch from a complete one.
        // See docs/AlarmProbeFindings.md.)
        bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);

        IReadOnlyList<MxAlarmTransitionEvent> transitions =
            FoldFetch(next, truncated, out int retainedCount);

        if (truncated)
        {
            WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount);
        }

        if (transitions.Count == 0) return;
        EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
        if (handler is null) return;
        foreach (MxAlarmTransitionEvent transition in transitions)
        {
            handler.Invoke(this, transition);
        }
    }

    /// <summary>
    ///     Folds one fetch into the retained state under a single lock: the
    ///     transition diff, the snapshot merge/replace, and the truncation
    ///     verdict move together. This is the write half of the pairing
    ///     <see cref="SnapshotActiveAlarms"/> reads; splitting either half would
    ///     let a reader see a capped snapshot alongside the previous poll's
    ///     "complete" verdict — precisely the false all-clear the signal exists
    ///     to prevent.
    ///     The verdict is replaced, never latched: a sub-cap fetch is complete
    ///     and restores absence authority, so leaving the flag set would strand
    ///     the operator banner on after a single burst.
    /// </summary>
    /// <param name="next">The snapshot just parsed from the fetch.</param>
    /// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
    /// <param name="retainedCount">Size of the retained snapshot after the fold.</param>
    /// <returns>The transitions the fetch implies.</returns>
    internal IReadOnlyList<MxAlarmTransitionEvent> FoldFetch(
        Dictionary<Guid, MxAlarmSnapshotRecord> next,
        bool truncated,
        out int retainedCount)
    {
        lock (syncRoot)
        {
            IReadOnlyList<MxAlarmTransitionEvent> transitions =
                ComputeTransitions(latestSnapshot, next);
            ApplySnapshotUpdate(latestSnapshot, next, truncated);
            lastSnapshotTruncated = truncated;
            retainedCount = latestSnapshot.Count;
            return transitions;
        }
    }

    /// <summary>
    ///     Decides whether a fetch that came back holding
    ///     <paramref name="fetchedRecordCount"/> records hit the cap.
    ///     <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
    ///     offers no confirmed "more available" flag, so a reply at exactly the
    ///     cap is indistinguishable from a galaxy that happens to hold exactly
    ///     that many active alarms; both are treated as truncated. The reply
    ///     root carries an <c>ALARM_RECORDS/@COUNT</c> attribute that would have
    ///     made the test exact had it reported the total active count; the
    ///     2026-08-18 live probe observed it tracking the records in the reply
    ///     instead (three alarms active returned @COUNT 1 at cap 1 and 2 at cap
    ///     2), so it carries no "more available" information and is deliberately
    ///     not trusted here (see <c>docs/AlarmProbeFindings.md</c>). Exposed as
    ///     <c>internal static</c> so the rule is unit-testable without the
    ///     wnwrapConsumer COM object.
    /// </summary>
    /// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
    /// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
    /// <returns><see langword="true"/> when the reply must be treated as truncated.</returns>
    internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch)
    {
        return fetchedRecordCount >= maxAlarmsPerFetch;
    }

    /// <summary>
    ///     Folds a freshly fetched snapshot into the retained one.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         A complete fetch is authoritative about <em>absence</em>:
    ///         replacing the snapshot wholesale is exactly what lets a cleared
    ///         alarm drop out of <see cref="SnapshotActiveAlarms"/>, which is
    ///         the evidence the gateway's reconcile pass turns into a Clear.
    ///     </para>
    ///     <para>
    ///         A truncated fetch is authoritative about <em>presence</em>
    ///         only. Merging rather than replacing means alarms the capped
    ///         reply did carry update normally, while alarms it had no room to
    ///         mention are retained untouched — so the gateway infers no Clear
    ///         from their absence. The cost is bounded staleness: a genuinely
    ///         cleared alarm can linger in the snapshot until the first
    ///         sub-cap fetch evicts it. That direction is the safe one for an
    ///         at-least-once alarm feed — a late Clear is repaired by the next
    ///         complete poll, whereas a Clear that never happened is broadcast
    ///         to every subscriber and cannot be taken back.
    ///     </para>
    /// </remarks>
    /// <param name="snapshot">The retained snapshot, updated in place.</param>
    /// <param name="next">The snapshot just parsed from the fetch.</param>
    /// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
    internal static void ApplySnapshotUpdate(
        Dictionary<Guid, MxAlarmSnapshotRecord> snapshot,
        Dictionary<Guid, MxAlarmSnapshotRecord> next,
        bool truncated)
    {
        if (snapshot is null) throw new ArgumentNullException(nameof(snapshot));
        if (next is null) throw new ArgumentNullException(nameof(next));

        if (!truncated)
        {
            snapshot.Clear();
        }

        foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
        {
            snapshot[kv.Key] = kv.Value;
        }
    }

    /// <summary>
    ///     Emits the truncated-fetch warning at most once per
    ///     <see cref="TruncationWarningIntervalMilliseconds"/>, counting every
    ///     truncated poll in between so the operator can tell a one-off burst
    ///     from a galaxy permanently parked above the cap.
    /// </summary>
    /// <param name="fetchedRecordCount">ALARM records the capped reply carried.</param>
    /// <param name="parsedRecordCount">Records that survived GUID parsing.</param>
    /// <param name="retainedCount">Snapshot size after the merge.</param>
    private void WarnTruncatedFetch(int fetchedRecordCount, int parsedRecordCount, int retainedCount)
    {
        long total;
        long elapsed;
        lock (syncRoot)
        {
            total = ++truncatedFetchCount;
            elapsed = truncationWarningClock.ElapsedMilliseconds;
            if (!ShouldWarnTruncation(elapsed, lastTruncationWarningMilliseconds))
            {
                return;
            }

            lastTruncationWarningMilliseconds = elapsed;
        }

        // Format matches WorkerConsoleLogger's "level=<L> event=<E> k=v k=v"
        // line shape so the worker's stderr parses uniformly. Deviation, on
        // purpose: IWorkerLogger exposes only Information and Error, so
        // "Warning" is a third level string no other worker line emits. A
        // truncated snapshot is not an error (the poll succeeded and the
        // snapshot is safe) but it is not routine either, so downgrading it to
        // Information would bury it. Revisit if the truncation signal becomes
        // structural — see docs/DesignDecisions.md.
        //
        // Identifiers and counts only — no tag names, values, limits, or
        // comments, per the gateway's "don't log tag values by default" rule.
        // The trailing note stays a single bare k=v token (no spaces, no
        // semicolons); remediation prose lives in docs/GatewayConfiguration.md.
        string message = string.Format(
            CultureInfo.InvariantCulture,
            "level=Warning event=AlarmSnapshotTruncated maxAlarmsPerFetch={0} fetchedRecords={1} "
            + "parsedRecords={2} retainedSnapshotSize={3} truncatedFetchesSinceStart={4} "
            + "note=truncated-snapshot-retained",
            maxAlarmsPerFetch,
            fetchedRecordCount,
            parsedRecordCount,
            retainedCount,
            total);

        Action<string> sink = TruncationWarningSink ?? Console.Error.WriteLine;
        sink(message);
    }

    /// <summary>
    ///     Rate-limit decision for the truncated-fetch warning: emit only when
    ///     a full <see cref="TruncationWarningIntervalMilliseconds"/> has
    ///     elapsed since the last one. A galaxy parked above the cap truncates
    ///     on every poll, so consecutive truncated polls inside one interval
    ///     must produce exactly one line. Exposed as <c>internal static</c>
    ///     so the throttle is unit-testable without the wnwrapConsumer COM
    ///     object.
    /// </summary>
    /// <param name="elapsedMilliseconds">Consumer-lifetime clock reading for this poll.</param>
    /// <param name="lastWarningMilliseconds">Clock reading when the last warning was emitted.</param>
    /// <returns><see langword="true"/> when this poll may emit a warning.</returns>
    internal static bool ShouldWarnTruncation(
        long elapsedMilliseconds,
        long lastWarningMilliseconds)
    {
        return elapsedMilliseconds - lastWarningMilliseconds >= TruncationWarningIntervalMilliseconds;
    }

    /// <summary>
    ///     Pure snapshot-to-transitions diff. Compares the previous polled
    ///     snapshot to the next snapshot and produces one
    ///     <see cref="MxAlarmTransitionEvent"/> per state change. Used by
    ///     <see cref="PollOnce"/> after a successful
    ///     <c>GetXmlCurrentAlarms2</c> call; exposed as <c>internal static</c>
    ///     so the diff rules can be unit-tested without driving the
    ///     wnwrapConsumer COM object.
    /// </summary>
    /// <remarks>
    ///     <para>Rules:</para>
    ///     <list type="bullet">
    ///         <item><description>A GUID present in <paramref name="next"/> but not in <paramref name="previous"/> produces a transition with <see cref="MxAlarmStateKind.Unspecified"/> as the previous state — first sighting.</description></item>
    ///         <item><description>A GUID present in both with the same <see cref="MxAlarmSnapshotRecord.State"/> produces no transition.</description></item>
    ///         <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
    ///         <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
    ///     </list>
    ///     <para>
    ///         The absence rule is deliberately inert here: this diff never
    ///         turns a disappearance into a Clear. The clear is inferred one
    ///         level up, by the gateway's reconcile pass diffing its cache
    ///         against <see cref="SnapshotActiveAlarms"/>, which reads the
    ///         snapshot <see cref="PollOnce"/> maintains from these same
    ///         dictionaries. That is why the truncation guard lives in
    ///         <see cref="PollOnce"/>'s snapshot update rather than in this
    ///         method: suppressing "absence implies Clear" means declining to
    ///         *evict* alarms a capped fetch could not mention, not declining
    ///         to emit a transition this method was never going to emit.
    ///         Passing a truncated <paramref name="next"/> straight through
    ///         stays correct for the alarms it does carry — first sightings
    ///         and state changes are computed from presence alone.
    ///     </para>
    ///     <para>
    ///         Every rule above assumes the GUID identifies the alarm
    ///         <em>instance</em> rather than the state it is in: a re-minted
    ///         GUID would read as the old alarm vanishing and a new one
    ///         appearing, i.e. a spurious Clear plus a spurious Raise. Live
    ///         capture confirms stability across the active→returned leg
    ///         (<c>docs/AlarmClientDiscovery.md</c>, reconfirmed 2026-08-18).
    ///         Re-raise-after-clear was observed on 2026-08-18 and does
    ///         <em>not</em> reuse the GUID: clearing left the returned record in
    ///         place under its original GUID and the re-raise replaced it with a
    ///         freshly minted one. That is the intended reading — the old
    ///         instance ended and a new one began — so the diff emits a Clear
    ///         plus a Raise, which is what happened. The acknowledge leg remains
    ///         assumed rather than observed: on the dev rig every ack surface is
    ///         inert (<c>AlarmAckByName</c> returns 0 and nothing moves), so no
    ///         record can be driven into an acknowledged state — see
    ///         <c>docs/AlarmProbeFindings.md</c>.
    ///     </para>
    /// </remarks>
    /// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
    /// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
    /// <returns>One transition per state change in <paramref name="next"/>.</returns>
    internal static IReadOnlyList<MxAlarmTransitionEvent> ComputeTransitions(
        Dictionary<Guid, MxAlarmSnapshotRecord> previous,
        Dictionary<Guid, MxAlarmSnapshotRecord> next)
    {
        if (previous is null) throw new ArgumentNullException(nameof(previous));
        if (next is null) throw new ArgumentNullException(nameof(next));

        List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
        foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
        {
            MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
            if (previous.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
            {
                previousState = prev.State;
                if (previousState == kv.Value.State) continue; // no transition
            }
            transitions.Add(new MxAlarmTransitionEvent
            {
                Record = kv.Value,
                PreviousState = previousState,
            });
        }
        return transitions;
    }

    /// <summary>
    ///     Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
    ///     into a GUID-keyed dictionary. Records with malformed GUIDs are
    ///     silently dropped (no fault is recorded — the next poll will
    ///     resync).
    /// </summary>
    /// <param name="xml">The XML snapshot payload.</param>
    /// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
    public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
    {
        return ParseSnapshotXml(xml, out _);
    }

    /// <summary>
    ///     <see cref="ParseSnapshotXml(string)"/> plus the raw
    ///     <c>ALARM</c>-element count, which <see cref="PollOnce"/> compares
    ///     against the per-fetch cap to detect a truncated reply. The raw
    ///     count — not the dictionary size — is the right signal: records
    ///     dropped for a malformed GUID still consumed a slot in the capped
    ///     reply, so counting only the survivors would under-report and let a
    ///     truncated fetch pass as complete.
    /// </summary>
    /// <param name="xml">The XML snapshot payload.</param>
    /// <param name="alarmRecordCount">Number of <c>ALARM</c> elements in the payload.</param>
    /// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
    internal static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(
        string xml,
        out int alarmRecordCount)
    {
        Dictionary<Guid, MxAlarmSnapshotRecord> records =
            new Dictionary<Guid, MxAlarmSnapshotRecord>();
        alarmRecordCount = 0;

        if (string.IsNullOrWhiteSpace(xml)) return records;

        XmlDocument doc = new XmlDocument();
        doc.LoadXml(xml);
        XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
        if (alarmNodes is null) return records;

        alarmRecordCount = alarmNodes.Count;

        foreach (XmlNode alarmNode in alarmNodes)
        {
            // One pass over the child elements instead of 17 SelectSingleNode
            // XPath evaluations per record. At the 1024-record cap that is the
            // difference between ~17k XPath expression compilations plus child
            // walks and one walk per record, on the STA that also serves reads
            // and writes.
            //
            // Parity with the XPath it replaces: SelectSingleNode(name) picks
            // the FIRST child ELEMENT of that name (ordinal, case-sensitive)
            // and yields its InnerText; an absent child yields string.Empty,
            // and so does a present-but-empty one. Hence: element nodes only,
            // first-match-wins (null means "not seen yet"), and a single
            // coalesce to string.Empty at the end — which keeps the absent and
            // empty cases indistinguishable exactly as before.
            //
            // One theoretical divergence: XmlNode.Name is the QName, so a child
            // carrying its own default-namespace declaration (<GUID xmlns="...">)
            // matches here where the unprefixed XPath name test would not. It is
            // theoretical — wnwrap emits no namespaces, and a namespaced ALARM
            // wrapper makes the outer SelectNodes return nothing either way — and
            // it errs permissive (field populated rather than silently dropped).
            string? guidHex = null;
            string? xmlDate = null;
            string? xmlTime = null;
            string? gmtOffset = null;
            string? dstAdjust = null;
            string? providerNode = null;
            string? providerName = null;
            string? group = null;
            string? tagName = null;
            string? type = null;
            string? value = null;
            string? limit = null;
            string? priority = null;
            string? state = null;
            string? operatorNode = null;
            string? operatorName = null;
            string? alarmComment = null;

            foreach (XmlNode child in alarmNode.ChildNodes)
            {
                if (child.NodeType != XmlNodeType.Element) continue;

                switch (child.Name)
                {
                    case "GUID": guidHex ??= child.InnerText; break;
                    case "DATE": xmlDate ??= child.InnerText; break;
                    case "TIME": xmlTime ??= child.InnerText; break;
                    case "GMTOFFSET": gmtOffset ??= child.InnerText; break;
                    case "DSTADJUST": dstAdjust ??= child.InnerText; break;
                    case "PROVIDER_NODE": providerNode ??= child.InnerText; break;
                    case "PROVIDER_NAME": providerName ??= child.InnerText; break;
                    case "GROUP": group ??= child.InnerText; break;
                    case "TAGNAME": tagName ??= child.InnerText; break;
                    case "TYPE": type ??= child.InnerText; break;
                    case "VALUE": value ??= child.InnerText; break;
                    case "LIMIT": limit ??= child.InnerText; break;
                    case "PRIORITY": priority ??= child.InnerText; break;
                    case "STATE": state ??= child.InnerText; break;
                    case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
                    case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
                    case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
                }
            }

            if (!TryParseHexGuid(guidHex, out Guid guid)) continue;

            DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
                xmlDate ?? string.Empty,
                xmlTime ?? string.Empty,
                ParseInt(gmtOffset ?? string.Empty),
                ParseInt(dstAdjust ?? string.Empty));

            records[guid] = new MxAlarmSnapshotRecord
            {
                AlarmGuid = guid,
                TransitionTimestampUtc = tsUtc,
                ProviderNode = providerNode ?? string.Empty,
                ProviderName = providerName ?? string.Empty,
                Group = group ?? string.Empty,
                TagName = tagName ?? string.Empty,
                Type = type ?? string.Empty,
                Value = value ?? string.Empty,
                Limit = limit ?? string.Empty,
                Priority = ParseInt(priority ?? string.Empty),
                State = AlarmRecordTransitionMapper.ParseStateKind(state ?? string.Empty),
                OperatorNode = operatorNode ?? string.Empty,
                OperatorName = operatorName ?? string.Empty,
                AlarmComment = alarmComment ?? string.Empty,
            };
        }
        return records;
    }

    private static int ParseInt(string text)
    {
        return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
            ? n : 0;
    }

    /// <summary>
    ///     wnwrap's XML <c>GUID</c> field is a 32-char hex string with no
    ///     dashes (e.g. <c>"BCC4705395424D65BDAABCDEA6A32A73"</c>). Convert
    ///     to <see cref="Guid"/>'s canonical 8-4-4-4-12 layout.
    /// </summary>
    /// <param name="hex">The 32-character hex GUID string.</param>
    /// <param name="guid">The parsed GUID, or Empty if parsing fails.</param>
    /// <returns><see langword="true"/> if <paramref name="hex"/> was successfully parsed; otherwise, <see langword="false"/>.</returns>
    public static bool TryParseHexGuid(string? hex, out Guid guid)
    {
        guid = Guid.Empty;
        if (string.IsNullOrWhiteSpace(hex)) return false;
        string trimmed = hex!.Trim();
        if (Guid.TryParse(trimmed, out guid)) return true;
        if (trimmed.Length != 32) return false;
        string canonical =
            trimmed.Substring(0, 8) + "-" +
            trimmed.Substring(8, 4) + "-" +
            trimmed.Substring(12, 4) + "-" +
            trimmed.Substring(16, 4) + "-" +
            trimmed.Substring(20, 12);
        return Guid.TryParse(canonical, out guid);
    }

    /// <summary>
    ///     Compose the XML payload <c>SetXmlAlarmQuery</c> expects from a
    ///     canonical subscription expression
    ///     (<c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c>). The wnwrap
    ///     consumer mangles the round-trip but evidently still needs the
    ///     call — without it <c>GetXmlCurrentAlarms2</c> fails with
    ///     E_FAIL. Best-effort parse: if the subscription doesn't decompose
    ///     cleanly, fall back to a permissive ALL-priority/ALL-state form
    ///     so the worker doesn't fail to start.
    /// </summary>
    /// <param name="subscription">The subscription expression.</param>
    /// <returns>The XML query payload to pass to <c>SetXmlAlarmQuery</c>.</returns>
    internal static string ComposeXmlAlarmQuery(string subscription)
    {
        string node = Environment.MachineName;
        string provider = "Galaxy";
        string group = string.Empty;

        if (!string.IsNullOrEmpty(subscription))
        {
            // Strip leading backslashes from "\\<node>\..." form.
            string trimmed = subscription.TrimStart('\\');
            int slash = trimmed.IndexOf('\\');
            if (slash > 0)
            {
                node = trimmed.Substring(0, slash);
                trimmed = trimmed.Substring(slash + 1);
            }
            int bang = trimmed.IndexOf('!');
            if (bang > 0)
            {
                provider = trimmed.Substring(0, bang);
                group = trimmed.Substring(bang + 1);
            }
            else
            {
                provider = trimmed;
            }
        }

        System.Text.StringBuilder sb = new System.Text.StringBuilder();
        sb.Append("<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">");
        sb.Append("<QUERY>");
        sb.Append("<NODE>").Append(node).Append("</NODE>");
        sb.Append("<PROVIDER>").Append(provider).Append("</PROVIDER>");
        if (!string.IsNullOrEmpty(group))
        {
            sb.Append("<GROUP>").Append(group).Append("</GROUP>");
        }
        sb.Append("</QUERY>");
        sb.Append("</QUERIES>");
        return sb.ToString();
    }

    private static VBGUID ToVbGuid(Guid g)
    {
        byte[] bytes = g.ToByteArray();
        // Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
        VBGUID vb = new VBGUID
        {
            Data1 = BitConverter.ToInt32(bytes, 0),
            Data2 = BitConverter.ToInt16(bytes, 4),
            Data3 = BitConverter.ToInt16(bytes, 6),
            Data4 = new byte[8],
        };
        Array.Copy(bytes, 8, vb.Data4, 0, 8);
        return vb;
    }

    /// <inheritdoc />
    public void Dispose()
    {
        wwAlarmConsumerClass? clientToDispose;
        wwAlarmConsumerClass? ackClientToDispose;
        lock (syncRoot)
        {
            if (disposed) return;
            disposed = true;
            clientToDispose = client;
            client = null;
            ackClientToDispose = ackClient;
            ackClient = null;
        }
        ReleaseConsumerCom(clientToDispose);
        ReleaseConsumerCom(ackClientToDispose);
    }

    private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
    {
        if (consumer is null) return;
        try { consumer.DeregisterConsumer(); } catch { /* swallow */ }
        try { consumer.UninitializeConsumer(); } catch { /* swallow */ }
        if (Marshal.IsComObject(consumer))
        {
            try { Marshal.FinalReleaseComObject(consumer); } catch { /* swallow */ }
        }
    }
}
