using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;

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

/// <summary>
///     Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
///     <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
///     item addresses, normalizes each <c>OnDataChange</c> COM callback into a
///     <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an
///     ack-comment) to a subtag.
/// </summary>
/// <remarks>
///     <para>
///         Like <see cref="WnWrapAlarmConsumer"/>, this type owns its
///         <em>own</em> MXAccess proxy-server COM object rather than sharing the
///         per-session item pipeline's <see cref="MxAccessSession"/>. That keeps
///         the subtag-fallback subscription isolated from the session's own
///         <c>Register</c>/<c>AddItem</c>/<c>Advise</c> bookkeeping: advising or
///         tearing down alarm subtags never perturbs the client's subscriptions.
///     </para>
///     <para>
///         <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
///         apartment-threaded and delivers <c>OnDataChange</c> via the STA
///         message pump. Every method on this type — the lazy <see cref="Advise"/>
///         creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be
///         invoked on the worker's STA that created the source, exactly as
///         <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
///         require. The <c>OnDataChange</c> COM event also arrives on that STA.
///         Because every call is confined to the single owning STA, no lock is
///         taken around the COM object or the handle maps: a lock here would risk
///         deadlocking the STA against a re-entrant pump callback, and the STA
///         affinity already serializes access.
///     </para>
/// </remarks>
public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
{
    private const string DefaultClientName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker.SubtagAlarms";

    private readonly IMxAccessComObjectFactory? factory;
    private readonly string clientName;
    private readonly Dictionary<string, int> itemHandlesByAddress =
        new Dictionary<string, int>(StringComparer.Ordinal);
    private readonly Dictionary<int, string> addressesByItemHandle =
        new Dictionary<int, string>();

    // Handles that were actually Advise()d, tracked separately from the added
    // set so Dispose only UnAdvises advised items. Write() can AddItem a
    // write-only subtag (e.g. an ack-comment that was never advised); calling
    // UnAdvise on such a handle would be an unbalanced teardown.
    private readonly HashSet<int> advisedItemHandles = new HashSet<int>();

    private object? mxAccessComObject;
    private IMxAccessServer? server;
    private LMXProxyServerClass? comEventSource;
    private int serverHandle;
    private bool registered;
    private bool disposed;

    /// <summary>
    ///     Production constructor. The MXAccess COM object is created lazily on
    ///     the first <see cref="Advise"/> call (which must run on the worker's
    ///     STA) via the supplied <paramref name="factory"/>.
    /// </summary>
    /// <param name="factory">Factory that creates the MXAccess proxy-server COM object.</param>
    /// <param name="clientName">
    ///     Optional MXAccess client/registration name used for
    ///     <see cref="IMxAccessServer.Register"/>; a worker-specific default is
    ///     used when null or whitespace. Mirrors the registration name
    ///     <see cref="MxAccessSession.Register"/> accepts.
    /// </param>
    /// <exception cref="ArgumentNullException"><paramref name="factory"/> is null.</exception>
    public LmxSubtagAlarmSource(
        IMxAccessComObjectFactory factory,
        string? clientName = null)
    {
        this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
        this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
    }

    /// <summary>
    ///     Test-only seam: constructs a source over a caller-supplied
    ///     <see cref="IMxAccessServer"/> with a pre-registered server handle,
    ///     bypassing the live COM factory. Exposed via
    ///     <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
    ///     exercise <see cref="Advise"/>/<see cref="Write"/> and
    ///     <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
    ///     No COM event source is wired in this mode — tests drive
    ///     <see cref="HandleDataChange"/> directly to simulate an
    ///     <c>OnDataChange</c> callback.
    /// </summary>
    /// <param name="server">The server abstraction to drive.</param>
    /// <param name="serverHandle">The already-registered server handle to use.</param>
    internal LmxSubtagAlarmSource(
        IMxAccessServer server,
        int serverHandle)
    {
        this.factory = null;
        this.clientName = DefaultClientName;
        this.server = server ?? throw new ArgumentNullException(nameof(server));
        this.serverHandle = serverHandle;
        this.registered = true;
    }

    /// <summary>Raised when an advised subtag reports a new value.</summary>
    public event EventHandler<SubtagValueChange>? ValueChanged;

    /// <inheritdoc />
    public void Advise(IReadOnlyCollection<string> itemAddresses)
    {
        if (itemAddresses is null)
        {
            throw new ArgumentNullException(nameof(itemAddresses));
        }

        ThrowIfDisposed();
        EnsureRegistered();

        IMxAccessServer mxServer = server!;
        foreach (string? itemAddress in itemAddresses)
        {
            if (string.IsNullOrWhiteSpace(itemAddress))
            {
                continue;
            }

            if (itemHandlesByAddress.ContainsKey(itemAddress!))
            {
                continue;
            }

            int itemHandle = mxServer.AddItem(serverHandle, itemAddress!);
            mxServer.Advise(serverHandle, itemHandle);
            itemHandlesByAddress[itemAddress!] = itemHandle;
            addressesByItemHandle[itemHandle] = itemAddress!;
            advisedItemHandles.Add(itemHandle);
        }
    }

    /// <inheritdoc />
    public void Write(string itemAddress, object? value)
    {
        if (itemAddress is null)
        {
            throw new ArgumentNullException(nameof(itemAddress));
        }

        ThrowIfDisposed();
        EnsureRegistered();

        IMxAccessServer mxServer = server!;
        if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
        {
            itemHandle = mxServer.AddItem(serverHandle, itemAddress);
            itemHandlesByAddress[itemAddress] = itemHandle;
            addressesByItemHandle[itemHandle] = itemAddress;
        }

        mxServer.Write(serverHandle, itemHandle, value, userId: 0);
    }

    /// <summary>
    ///     Normalizes an MXAccess <c>OnDataChange</c> callback into a
    ///     <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
    ///     when the item handle maps to an advised address. Unknown handles are
    ///     ignored. Exposed <c>internal</c> as a unit-test seam so the
    ///     normalization can be exercised without a live <c>LMXProxyServerClass</c>,
    ///     mirroring <c>MxAccessBaseEventSink.OnDataChange</c>.
    /// </summary>
    /// <param name="phItemHandle">The MXAccess item handle that changed.</param>
    /// <param name="pvItemValue">The new item value (left as a boxed COM variant; the state machine coerces it).</param>
    /// <param name="pftItemTimeStamp">The item timestamp as delivered by MXAccess (a culture-formatted string).</param>
    internal void HandleDataChange(
        int phItemHandle,
        object? pvItemValue,
        object? pftItemTimeStamp)
    {
        if (!addressesByItemHandle.TryGetValue(phItemHandle, out string? itemAddress))
        {
            return;
        }

        // MXAccess delivers OnDataChange timestamps as a culture-formatted
        // string (VT_BSTR), not a FILETIME or VT_DATE — see
        // MxAccessEventMapper.TryParseSourceTimestamp, which is the single
        // conversion helper the per-session pipeline already uses. Reuse it so
        // the subtag path interprets the host-local string the same way; fall
        // back to capture time when the string is absent or unparsable.
        DateTime timestampUtc = ConvertTimestampUtc(pftItemTimeStamp);

        EventHandler<SubtagValueChange>? handler = ValueChanged;
        handler?.Invoke(this, new SubtagValueChange
        {
            ItemAddress = itemAddress,
            Value = pvItemValue,
            TimestampUtc = timestampUtc,
        });
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;

        // Unsubscribe the COM event before tearing handles down so a late pump
        // callback cannot re-enter HandleDataChange mid-teardown.
        if (comEventSource is not null)
        {
            try { comEventSource.OnDataChange -= OnDataChange; } catch { /* swallow — best effort */ }
        }

        IMxAccessServer? mxServer = server;
        if (mxServer is not null && registered)
        {
            foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
            {
                // Only UnAdvise handles that were actually advised; a write-only
                // item (added by Write but never Advise'd) was never advised.
                if (advisedItemHandles.Contains(entry.Key))
                {
                    try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
                }

                try { mxServer.RemoveItem(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
            }

            try { mxServer.Unregister(serverHandle); } catch { /* swallow — best effort */ }
        }

        itemHandlesByAddress.Clear();
        addressesByItemHandle.Clear();
        advisedItemHandles.Clear();

        object? comToRelease = mxAccessComObject;
        mxAccessComObject = null;
        comEventSource = null;
        server = null;
        registered = false;

        if (comToRelease is not null && Marshal.IsComObject(comToRelease))
        {
            try { Marshal.FinalReleaseComObject(comToRelease); } catch { /* swallow — best effort */ }
        }
    }

    private void EnsureRegistered()
    {
        if (registered)
        {
            return;
        }

        // factory is only null in the test-only constructor, which sets
        // registered = true, so this path is never hit there.
        object created = factory!.Create()
            ?? throw new InvalidOperationException("MXAccess COM factory returned null.");

        try
        {
            mxAccessComObject = created;

            // Wire OnDataChange the same way MxAccessBaseEventSink.Attach does:
            // cast to the concrete LMXProxyServerClass RCW and subscribe. Test
            // doubles cannot be cast to LMXProxyServerClass, so the COM event
            // subscription is exercised only on the live path; unit tests drive
            // HandleDataChange directly via the internal constructor seam.
            if (created is LMXProxyServerClass comObject)
            {
                comEventSource = comObject;
                comObject.OnDataChange += OnDataChange;
            }

            server = new MxAccessComServer(created);
            serverHandle = server.Register(clientName);
            registered = true;
        }
        catch
        {
            if (comEventSource is not null)
            {
                try { comEventSource.OnDataChange -= OnDataChange; } catch { /* swallow */ }
                comEventSource = null;
            }

            if (Marshal.IsComObject(created))
            {
                try { Marshal.FinalReleaseComObject(created); } catch { /* swallow */ }
            }

            mxAccessComObject = null;
            server = null;
            throw;
        }
    }

    /// <summary>
    ///     COM <c>OnDataChange</c> delegate target. Forwards to
    ///     <see cref="HandleDataChange"/>; the quality, status array, and server
    ///     handle carry no information the subtag fallback needs.
    /// </summary>
    private void OnDataChange(
        int hLMXServerHandle,
        int phItemHandle,
        object pvItemValue,
        int pwItemQuality,
        object pftItemTimeStamp,
        ref MXSTATUS_PROXY[] pVars)
    {
        HandleDataChange(phItemHandle, pvItemValue, pftItemTimeStamp);
    }

    private static DateTime ConvertTimestampUtc(object? pftItemTimeStamp)
    {
        if (pftItemTimeStamp is not null
            && MxAccessEventMapper.TryParseSourceTimestamp(pftItemTimeStamp.ToString(), out DateTime parsedUtc))
        {
            return parsedUtc;
        }

        return DateTime.UtcNow;
    }

    private void ThrowIfDisposed()
    {
        if (disposed)
        {
            throw new ObjectDisposedException(nameof(LmxSubtagAlarmSource));
        }
    }
}
