using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Linq;
using System.Reflection;
using AlarmMgrDataProviderCOM;
using aaAlarmManagedClient;
using ArchestrA.MxAccess;
using Xunit.Abstractions;

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

/// <summary>
///     Runtime probe — registers as an AlarmClient consumer with a real
///     hidden message-only window, subscribes to a Galaxy alarm provider,
///     and logs every Win32 message that arrives during a fixed pump
///     window. The intent is to identify the WM_APP / RegisterWindowMessage
///     ID that AVEVA's alarm provider posts when alarms change, plus the
///     <c>wParam</c>/<c>lParam</c> semantics on each.
///
///     Skip-gated by default; flip Skip=null and run against the live dev
///     rig to capture output. Requires:
///     <list type="bullet">
///         <item><description>A reachable Galaxy with at least one alarmable object.</description></item>
///         <item><description>The configured Galaxy expression below to match a real provider (default <c>"\\Galaxy"</c> — adjust if needed).</description></item>
///         <item><description>An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured.</description></item>
///     </list>
/// </summary>
public sealed class AlarmClientWmProbeTests : IDisposable
{
    // Probe configuration. Override in the constructor below if needed.
    // Try multiple subscription expressions sequentially (each Subscribe call
    // adds to the consumer's scope). The "everything" form varies by AVEVA
    // version — we shotgun common forms.
    // Canonical AlarmClient subscription format (per ArchestrA docs):
    //   \\Node\Provider!Area!Filter
    // - Node: machine name (NOT galaxy name; "Galaxy" is the literal provider)
    // - Provider: literal "Galaxy"
    // - Area: area object the engine hosts the alarm under
    // Note: each Subscribe call REPLACES the prior subscription on the
    // consumer, so we test exactly one expression per probe run.
    private static readonly string MachineName = Environment.MachineName;
    private static readonly string[] SubscriptionExpressions =
    {
        // DEV is the top-level area on the Platform (TestArea is contained
        // within DEV). Alarms typically publish at the platform's primary
        // area. If TestArea-only doesn't catch them, DEV should.
        $@"\\{MachineName}\Galaxy!DEV",
    };
    private const string SubscriptionExpression = @"\Galaxy!";
    private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(60);
    private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
    private static readonly TimeSpan FireMarkerAt = TimeSpan.FromSeconds(10);
    private static readonly TimeSpan ClearMarkerAt = TimeSpan.FromSeconds(35);
    // Tag the operator should flip while the probe is pumping. Default
    // matches the dev rig's known alarmable boolean.
    private const string TriggerTagReference = "TestMachine_001.TestAlarm001";

    [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
    private static extern IntPtr CreateWindowEx(
        int dwExStyle, string lpClassName, string lpWindowName,
        int dwStyle, int X, int Y, int nWidth, int nHeight,
        IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);

    [DllImport("user32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool DestroyWindow(IntPtr hWnd);

    [DllImport("user32.dll", SetLastError = true)]
    private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass);

    [DllImport("user32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);

    [DllImport("user32.dll")]
    private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);

    [DllImport("user32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);

    [DllImport("user32.dll")]
    private static extern IntPtr DispatchMessage(ref MSG lpMsg);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool TranslateMessage(ref MSG lpMsg);

    [DllImport("kernel32.dll")]
    private static extern IntPtr GetModuleHandle(string lpModuleName);

    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    private static extern uint RegisterWindowMessage(string lpString);

    private const int HWND_MESSAGE = -3;
    private const uint PM_REMOVE = 0x0001;

    private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct WNDCLASSW
    {
        public uint style;
        public IntPtr lpfnWndProc;
        public int cbClsExtra;
        public int cbWndExtra;
        public IntPtr hInstance;
        public IntPtr hIcon;
        public IntPtr hCursor;
        public IntPtr hbrBackground;
        [MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName;
        [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct MSG
    {
        public IntPtr hwnd;
        public uint message;
        public IntPtr wParam;
        public IntPtr lParam;
        public uint time;
        public int x;
        public int y;
    }

    private readonly ITestOutputHelper output;
    private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
    private readonly Stopwatch elapsed = Stopwatch.StartNew();
    private GCHandle wndProcHandle;
    private IntPtr probeWindow = IntPtr.Zero;
    private string? registeredClass;

    /// <summary>Initializes a new instance of the test class.</summary>
    /// <param name="output">The test output helper.</param>
    public AlarmClientWmProbeTests(ITestOutputHelper output)
    {
        this.output = output;
    }

    /// <summary>Probes the alarm client for window message behavior (requires AVEVA installed).</summary>
    [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
    public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages()
    {
        // 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
        //    matches in the captured log can be labeled. None of these is
        //    confirmed; we record what each resolves to so the actual AVEVA
        //    message ID (whatever it turns out to be) can be cross-referenced.
        string[] candidateNames =
        {
            "WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm",
            "WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify",
            "WNAL_Notify", "WNAL_ChangeNotification",
            "AlarmManager.Notify", "AlarmManagerNotify",
            "ArchestrA.AlarmChange", "AVEVA.AlarmNotify",
            "aaAlarmManagedClient.Notify",
            "GotAlarmChanges", "OnAlarmChanges",
        };
        foreach (string name in candidateNames)
        {
            uint id = RegisterWindowMessage(name);
            output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})");
        }
        output.WriteLine("");

        // 2. Spin up a single STA-affinitized thread, create a hidden message-
        //    only window owned by it, run RegisterConsumer + Subscribe against
        //    that window's hWnd, then pump messages on that thread for the
        //    configured duration. Threading discipline matches the worker's
        //    StaRuntime model.
        Exception? threadException = null;
        var pumpDone = new ManualResetEventSlim(false);
        var thread = new Thread(() =>
        {
            try
            {
                RunProbe();
            }
            catch (Exception ex)
            {
                threadException = ex;
            }
            finally
            {
                pumpDone.Set();
            }
        });
        thread.IsBackground = false;
        thread.SetApartmentState(ApartmentState.STA);
        thread.Start();
        pumpDone.Wait();
        thread.Join();

        // 3. Drain the log to xunit output regardless of outcome — partial
        //    captures are still informative.
        output.WriteLine("");
        output.WriteLine($"Captured {log.Count} log line(s):");
        while (log.TryDequeue(out string? line))
        {
            output.WriteLine(line);
        }

        if (threadException != null)
        {
            throw threadException;
        }
    }

    private void RunProbe()
    {
        // 3a. Register a window class and create a message-only window.
        WndProc wndProc = ProbeWndProc;
        wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive

        registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N");
        var cls = new WNDCLASSW
        {
            style = 0,
            lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc),
            hInstance = GetModuleHandle(null!),
            lpszClassName = registeredClass,
        };
        ushort atom = RegisterClassW(ref cls);
        if (atom == 0)
        {
            int err = Marshal.GetLastWin32Error();
            Log($"RegisterClass failed err=0x{err:X8}");
            return;
        }
        Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}");

        probeWindow = CreateWindowEx(
            dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe",
            dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0,
            hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero,
            hInstance: cls.hInstance, lpParam: IntPtr.Zero);
        if (probeWindow == IntPtr.Zero)
        {
            int err = Marshal.GetLastWin32Error();
            Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}");
            return;
        }
        Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}");

        // 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer
        //    accepts an int hWnd — narrow the IntPtr (sufficient on x86).
        AlarmClient? client = null;
        try
        {
            client = new AlarmClient();

            // One-time interop introspection: dump AlarmClient's class GUID
            // (CoClass IID) and every interface it implements with their
            // GUID + InterfaceType. The IID we need to redeclare with safe
            // blittable types is the one whose vtable carries
            // GetHighPriAlarm.
            try
            {
                Type ct = client.GetType();
                Log($"=== AlarmClient interop introspection ===");
                Log($"Class FullName: {ct.FullName}");
                var classGuid = ct.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
                    .Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
                Log($"Class GUID: {classGuid?.Value ?? "(none)"}");
                foreach (var iface in ct.GetInterfaces())
                {
                    var ig = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
                        .Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
                    var ity = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.InterfaceTypeAttribute), true)
                        .Cast<System.Runtime.InteropServices.InterfaceTypeAttribute>().FirstOrDefault();
                    int methodCount = iface.GetMethods().Length;
                    Log($"  iface {iface.FullName} | GUID={ig?.Value ?? "(none)"} | type={ity?.Value.ToString() ?? "(none)"} | methods={methodCount}");
                }
                // Dump fields (private/internal) — the COM object reference
                // is likely on a private field.
                Log($"--- AlarmClient instance fields ---");
                foreach (var f in ct.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
                {
                    Log($"  field {f.FieldType.FullName} {f.Name} (public={f.IsPublic})");
                }
                // Dump base class chain.
                Log($"--- base class chain ---");
                Type? baseT = ct.BaseType;
                int depth = 0;
                while (baseT != null && depth < 5)
                {
                    Log($"  base[{depth}]: {baseT.FullName}");
                    baseT = baseT.BaseType;
                    depth++;
                }
                Log($"=== end introspection ===");
            }
            catch (Exception ex)
            {
                Log($"Interop introspection threw: {ex.GetType().Name}: {ex.Message}");
            }

            // Try InitializeConsumer first — separate from RegisterConsumer
            // per the discovered API surface; previous probe runs skipped
            // it. Some AVEVA managed-client patterns require Initialize
            // before Register; others reverse the order. Try Initialize
            // first; on failure proceed to Register.
            try
            {
                int init = client.InitializeConsumer("AlarmProbe.Tests");
                Log($"InitializeConsumer -> {init}");
            }
            catch (Exception ex)
            {
                Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
            }

            int register = client.RegisterConsumer(
                hWnd: probeWindow.ToInt32(),
                szProductName: "AlarmProbe",
                szApplicationName: "AlarmProbe.Tests",
                szVersion: "1.0",
                bRetainHiddenAlarms: false);
            Log($"RegisterConsumer -> {register}");

            LogProviders(client, "after Register");

            // Dump the eQueryType enum so we can see what alternatives exist
            // beyond qtSummary, in case Summary aggregates and we need a
            // List/Snapshot mode instead.
            try
            {
                Type qt = typeof(eQueryType);
                Log($"eQueryType enum values: " +
                    string.Join(", ", Enum.GetNames(qt).Select(n =>
                        $"{n}=0x{Convert.ToInt32(Enum.Parse(qt, n)):X}")));
                Type af = typeof(eAlarmFilterState);
                Log($"eAlarmFilterState enum values: " +
                    string.Join(", ", Enum.GetNames(af).Select(n =>
                        $"{n}=0x{Convert.ToInt32(Enum.Parse(af, n)):X}")));
            }
            catch (Exception ex)
            {
                Log($"Enum dump threw: {ex.Message}");
            }

            // qtHistory + state=ActiveNow: stream historical alarm transitions
            // including active alarms. asNone for FilterMask/Spec might
            // literally mean "match alarms in state 'none'" (i.e., nothing),
            // since the eAlarmFilterState enum is 0/1/2/3 single-states not
            // flag bits. Try ActiveNow explicitly.
            // Subscribe to every candidate expression — AVEVA accepts multiple
            // overlapping subscriptions; whichever matches the producer wins.
            foreach (string expr in SubscriptionExpressions)
            {
                try
                {
                    int subscribe = client.Subscribe(
                        szSubscription: expr,
                        wFromPri: 0, wToPri: short.MaxValue,
                        QueryType: eQueryType.qtSummary,
                        SortFlags: eSortFlags.sfReturnNewestFirst,
                        FilterMask: eAlarmFilterState.asAlarmActiveNow,
                        FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
                    Log($"Subscribe('{expr}') -> {subscribe}");
                }
                catch (Exception ex)
                {
                    Log($"Subscribe('{expr}') threw: {ex.GetType().Name}: {ex.Message}");
                }
            }

            LogProviders(client, "after Subscribe-multi");

            // 3c. Pump for the configured duration. Log every message we see
            //    (filtered light to avoid noise from WM_PAINT / WM_TIMER /
            //    WM_GETICON spam from typical pumps). Poll GetStatistics on
            //    a tight cadence so any alarm transition is captured. Print
            //    "fire" / "clear" markers at fixed wallclock offsets so the
            //    operator can flip the trigger boolean during the run.
            Log($"Probe running for {PumpDuration.TotalSeconds:F0}s. " +
                $"Observing {TriggerTagReference} alarm transitions. " +
                "External trigger expected from System Platform script (10s flip cadence).");

            DateTime probeStart = DateTime.UtcNow;
            DateTime deadline = probeStart + PumpDuration;
            DateTime nextPoll = probeStart + PollInterval;
            int pollCount = 0;

            while (DateTime.UtcNow < deadline)
            {
                while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE))
                {
                    LogIfInteresting(msg);
                    TranslateMessage(ref msg);
                    DispatchMessage(ref msg);
                }
                // Trigger is supplied externally — a System Platform script
                // flips TestMachine_001.TestAlarm001 every 10s. The probe
                // observes only.
                if (DateTime.UtcNow >= nextPoll)
                {
                    PollGetStatistics(client, ++pollCount);
                    LogProviders(client, $"poll #{pollCount}");
                    PollAllChannels(client, pollCount);
                    nextPoll = DateTime.UtcNow + PollInterval;
                }
                Thread.Sleep(10);
            }

            Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering.");
            Log($"GetHighPriAlarm tally: ok-with-record={getHighPriOk} threw={getHighPriThrow} " +
                $"(throws indicate alarm-record marshaling failure; ok=empty record).");

            try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
            catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
        }
        finally
        {
            try { client?.Dispose(); } catch { /* swallow */ }
            if (probeWindow != IntPtr.Zero)
            {
                DestroyWindow(probeWindow);
                probeWindow = IntPtr.Zero;
            }
            if (registeredClass != null)
            {
                UnregisterClassW(registeredClass, GetModuleHandle(null!));
            }
        }
    }

    private string lastStatsSummary = string.Empty;
    private string lastProvidersSummary = string.Empty;
    private string lastHighPriSummary = string.Empty;
    private string lastSfStatsSummary = string.Empty;
    private int getHighPriOk = 0;
    private int getHighPriThrow = 0;

    /// <summary>
    ///     Try every read API the AlarmClient exposes and log when its
    ///     output changes. AlarmClient has at least three distinct read
    ///     surfaces — GetStatistics (current-change array), GetHighPriAlarm
    ///     (single-record peek), and the SF (stored filter) family — and any
    ///     of them might be the populated one.
    /// </summary>
    private static AlarmRecord NewAlarmRecord()
    {
        // The interop's auto-marshal flips DateTime fields to FILETIME on
        // the way IN as well as OUT. default(DateTime) (year 1) is outside
        // FILETIME's representable range, so initialize all DateTime fields
        // to the FILETIME epoch (1601-01-01 UTC) to satisfy the marshaler.
        AlarmRecord rec = new AlarmRecord();
        DateTime epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        foreach (var f in typeof(AlarmRecord).GetFields(
            BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
        {
            if (f.FieldType == typeof(DateTime))
            {
                object boxed = rec;
                f.SetValue(boxed, epoch);
                rec = (AlarmRecord)boxed;
            }
        }
        return rec;
    }

    private void PollAllChannels(AlarmClient client, int seq)
    {
        // Channel A: GetHighPriAlarm — peek highest-priority alarm. Track
        // outcome state (record/empty/throw) and log every transition AND
        // total counts at end. The throw correlates with an alarm being
        // present (AVEVA fills timestamps with sentinel FILETIME values
        // that crash the .NET marshaler) — useful as a presence signal
        // even if we can't read the record.
        try
        {
            AlarmRecord rec = NewAlarmRecord();
            int rc = client.GetHighPriAlarm(ref rec);
            string desc = rc == 0 ? DescribeAlarmRecord(rec) : "<no record>";
            string summary = $"rc={rc} {desc}";
            getHighPriOk++;
            if (summary != lastHighPriSummary)
            {
                Log($"GetHighPriAlarm #{seq}: {summary} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
                lastHighPriSummary = summary;
            }
        }
        catch (Exception ex)
        {
            string es = $"{ex.GetType().Name}";
            getHighPriThrow++;
            if (es != lastHighPriSummary)
            {
                Log($"GetHighPriAlarm #{seq}: threw {es} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
                lastHighPriSummary = es;
            }
        }

        // Channel C: GetAlarmExtendedRec by index. Try indices 0..3 directly;
        // populated alarms (if any) appear at low indices.
        for (int idx = 0; idx <= 2; idx++)
        {
            try
            {
                AlarmRecord rec = NewAlarmRecord();
                int rc = client.GetAlarmExtendedRec(idx, ref rec);
                if (rc == 0)
                {
                    string desc = DescribeAlarmRecord(rec);
                    Log($"GetAlarmExtendedRec(idx={idx}) #{seq}: rc=0 -> {desc}");
                    break; // log first present record only
                }
            }
            catch (Exception ex)
            {
                if (idx == 0)
                {
                    Log($"GetAlarmExtendedRec(idx=0) #{seq}: threw {ex.GetType().Name}: {ex.Message}");
                }
                break;
            }
        }

        // Channel B: SF — snapshot + GetStatistics + iterate.
        try
        {
            uint numAlarms = 0;
            int sfCreate = client.SFCreateSnapshot(0, ref numAlarms);
            int unackRet = 0, unackAlm = 0, ackAlm = 0, others = 0, events = 0, idxNewest = 0;
            int sfStats = client.SFGetStatistics(
                ref unackRet, ref unackAlm, ref ackAlm,
                ref others, ref events, ref idxNewest);
            string summary = $"SFCreate={sfCreate} numAlarms={numAlarms} " +
                             $"SFStats={sfStats} unackRet={unackRet} unackAlm={unackAlm} " +
                             $"ackAlm={ackAlm} others={others} events={events} idxNewest={idxNewest}";
            if (summary != lastSfStatsSummary)
            {
                Log($"SF channel #{seq}: {summary} (changed)");
                lastSfStatsSummary = summary;

                // If non-zero, fetch the first record by index via the
                // standard GetAlarmExtendedRec — after SFCreateSnapshot the
                // indices reference the snapshot.
                if (numAlarms > 0)
                {
                    AlarmRecord rec = new AlarmRecord();
                    int recRc = client.GetAlarmExtendedRec(0, ref rec);
                    Log($"  GetAlarmExtendedRec(0) [post-snapshot] rc={recRc} -> {DescribeAlarmRecord(rec)}");
                }
            }
            client.SFDeleteSnapshot();
        }
        catch (Exception ex)
        {
            Log($"SF channel #{seq}: threw {ex.GetType().Name}: {ex.Message}");
        }
    }


    private void LogProviders(AlarmClient client, string when)
    {
        try
        {
            var providers = new System.Collections.Generic.List<string>();
            int rc = client.GetProviders(providers);
            string summary = $"count={providers.Count} list=[{string.Join(", ", providers)}]";
            if (summary != lastProvidersSummary)
            {
                Log($"GetProviders [{when}] -> rc={rc} {summary} (changed)");
                lastProvidersSummary = summary;
            }
        }
        catch (Exception ex)
        {
            Log($"GetProviders [{when}] threw: {ex.GetType().Name}: {ex.Message}");
        }
    }

    /// <summary>
    ///     Drive an MxAccess write to <see cref="TriggerTagReference"/> with the
    ///     supplied boolean value. Creates a fresh `LMXProxyServer` COM object,
    ///     registers, adds the item, writes the value, and tears down. Runs on
    ///     the same STA thread the probe uses for the AlarmClient — both COM
    ///     objects share the apartment, which matches the worker's runtime.
    /// </summary>
    private void TriggerWriteValue(bool value, int sequence)
    {
        object? lmx = null;
        ILMXProxyServer? srv = null;
        int handle = 0, itemHandle = 0;
        try
        {
            lmx = new LMXProxyServerClass();
            srv = (ILMXProxyServer)lmx;
            handle = srv.Register($"AlarmProbe.Trigger.{sequence}");
            Log($"Trigger write #{sequence}: Register -> handle={handle}");
            itemHandle = srv.AddItem(handle, TriggerTagReference);
            Log($"Trigger write #{sequence}: AddItem('{TriggerTagReference}') -> itemHandle={itemHandle}");

            // First time only: dump every Write* method's signature so we know
            // which to call. The first attempt hit TargetParameterCountException —
            // the LMX server has multiple Write variants and we picked wrong.
            if (sequence == 1)
            {
                Log($"Trigger write #{sequence}: enumerating Write* methods on {lmx.GetType().FullName}:");
                foreach (var m in lmx.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance))
                {
                    if (m.IsSpecialName) continue;
                    if (!m.Name.StartsWith("Write", StringComparison.OrdinalIgnoreCase)) continue;
                    string ps = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
                    Log($"  {m.ReturnType.Name} {m.Name}({ps})");
                }
            }

            // Late-bind Write — it isn't on ILMXProxyServer's interface but is
            // exposed by the COM coclass.
            object[] writeArgs = new object[] { handle, itemHandle, value };
            object? rv = lmx.GetType().InvokeMember(
                "Write",
                BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
                binder: null, target: lmx, args: writeArgs);
            Log($"Trigger write #{sequence}: Write({TriggerTagReference}={value}) -> rv={rv}");
        }
        catch (Exception ex)
        {
            Log($"Trigger write #{sequence}: FAILED: {ex.GetType().Name}: {ex.Message}");
            if (ex.InnerException != null)
            {
                Log($"  inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
            }
        }
        finally
        {
            try
            {
                if (srv != null && itemHandle != 0) { srv.RemoveItem(handle, itemHandle); }
                if (srv != null && handle != 0) { srv.Unregister(handle); }
            }
            catch (Exception ex)
            {
                Log($"Trigger write #{sequence}: cleanup failure: {ex.GetType().Name}: {ex.Message}");
            }
            if (lmx != null && System.Runtime.InteropServices.Marshal.IsComObject(lmx))
            {
                try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(lmx); }
                catch { /* swallow */ }
            }
        }
    }


    private void PollGetStatistics(AlarmClient client, int seq)
    {
        try
        {
            int percent = 0, total = 0, active = 0, suppressed = 0;
            int suppressedFilters = 0, newAlarms = 0, changes = 0;
            int[] codes = Array.Empty<int>();
            int[] positions = Array.Empty<int>();
            int[] handles = Array.Empty<int>();
            int rc = client.GetStatistics(
                ref percent, ref total, ref active, ref suppressed,
                ref suppressedFilters, ref newAlarms, ref changes,
                ref codes, ref positions, ref handles);
            string codesStr = codes != null ? string.Join(",", codes) : "<null>";
            string posStr = positions != null ? string.Join(",", positions) : "<null>";
            string handlesStr = handles != null ? string.Join(",", handles) : "<null>";
            int posLen = positions?.Length ?? 0;

            // Suppress duplicate-summary spam — only log when interesting
            // state-change is observed. The "interesting" digest excludes
            // percent (always 100 at steady state).
            string summary = $"total={total} active={active} suppressed={suppressed} " +
                             $"new={newAlarms} changes={changes} codes=[{codesStr}] " +
                             $"positions=[{posStr}] handles=[{handlesStr}]";
            if (summary != lastStatsSummary)
            {
                Log($"GetStatistics #{seq} rc={rc} pct={percent} {summary} (changed)");
                lastStatsSummary = summary;
            }

            // Always fetch records when positions has entries — records
            // change content even when count stays the same.
            if (posLen > 0 && positions != null)
            {
                for (int i = 0; i < Math.Min(posLen, 4); i++)
                {
                    int idx = positions[i];
                    AlarmRecord rec = new AlarmRecord();
                    int recRc = client.GetAlarmExtendedRec(idx, ref rec);
                    Log($"  GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " +
                        DescribeAlarmRecord(rec));
                }
            }
        }
        catch (Exception ex)
        {
            Log($"GetStatistics #{seq} threw: {ex.GetType().Name}: {ex.Message}");
        }
    }

    private static string DescribeAlarmRecord(AlarmRecord rec)
    {
        // Reflect over the record's public properties so we don't have to
        // guess the field shape — the discovery probe already showed it has
        // ar_AlarmName / ar_Provider / ar_Group / ar_AlmTransition / etc.
        var sb = new System.Text.StringBuilder();
        sb.Append("{ ");
        bool first = true;
        foreach (var prop in rec.GetType().GetProperties(
            System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
        {
            try
            {
                object? v = prop.GetValue(rec);
                string vs = v?.ToString() ?? "<null>";
                if (vs.Length > 50) vs = vs.Substring(0, 47) + "...";
                if (!first) sb.Append(", ");
                sb.Append($"{prop.Name}={vs}");
                first = false;
            }
            catch
            {
                // skip failing accessors
            }
        }
        sb.Append(" }");
        return sb.ToString();
    }

    private void LogIfInteresting(MSG m)
    {
        // Filter out the highest-volume noise (timer ticks, paint, mouse moves
        // from a desktop session). Keep WM_USER..WM_APP+ entirely; those are
        // the candidates for the AVEVA-registered message.
        const uint WM_PAINT = 0x000F;
        const uint WM_TIMER = 0x0113;
        const uint WM_MOUSEMOVE = 0x0200;
        const uint WM_NCMOUSEMOVE = 0x00A0;
        if (m.message == WM_PAINT || m.message == WM_TIMER ||
            m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE)
        {
            return;
        }

        string interpreted = InterpretMessageId(m.message);
        Log(string.Format(
            "WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}",
            m.message, interpreted,
            m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF,
            m.hwnd.ToInt64()));
    }

    private static string InterpretMessageId(uint id)
    {
        if (id < 0x0400) return "WM_<system>";
        if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}";
        if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}";
        return $"RegisterWindowMessage_0x{id:X4}";
    }

    private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
    {
        // Log every WM that lands on the probe window itself.
        string interpreted = InterpretMessageId(msg);
        Log(string.Format(
            "WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}",
            msg, interpreted,
            wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF));
        return DefWindowProcW(hWnd, msg, wParam, lParam);
    }

    private void Log(string line)
    {
        log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (wndProcHandle.IsAllocated) wndProcHandle.Free();
    }
}
