using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;

/// <summary>
///     Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
///     <c>BaseEventType</c> identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. All three
///     previously arrived <b>null</b> on every condition event: <c>MaterialiseAlarmCondition</c> never assigned
///     them, and nothing downstream synthesises them (<c>ReportEvent</c> / <c>InstanceStateSnapshot</c> copy the
///     condition's children verbatim), so a conforming client could not attribute an alarm to its source.
///
///     <para>This is the WIRE-level guard the node-level
///     <c>NodeManagerAlarmSourceFieldsTests</c> cannot give: it proves the values survive the snapshot +
///     serialization all the way into a real client's <c>EventFieldList</c>. The select clause is deliberately
///     the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
///     <c>[EventType, SourceNode, SourceName, Time, Message, Severity]</c> — so this test fails the way that
///     client failed.</para>
///
///     <para>Boots the real <see cref="OtOpcUaSdkServer"/> and drives the production
///     <see cref="SdkAddressSpaceSink"/>, mirroring <c>NativeAlarmMultiNotifierEventDeliveryTests</c>. Heavy
///     in-process server+client integration — runs in the serial integration pass.</para>
/// </summary>
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
{
    private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
    private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
    private const string QualityServerUri = "urn:OtOpcUa.AlarmEventQualityField";

    private const string RawDeviceFolder = "pymodbus/plc";
    private const string RawAlarmPath = "pymodbus/plc/HR200";
    private const string LeafName = "HR200";
    private const string Equip1 = "EQ-filling-line1-station1";
    private const string AlarmMessage = "HR200 over limit (identity-field test)";

    // Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
    private const int EventTypeIndex = 0;
    private const int SourceNodeIndex = 1;
    private const int SourceNameIndex = 2;
    private const int TimeIndex = 3;
    private const int MessageIndex = 4;
    private const int SeverityIndex = 5;

    // Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the
    // ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing,
    // so appending to it would silently shift them. Message is re-selected here only as the collector's filter key.
    private const int ConditionClassIdIndex = 0;
    private const int ConditionClassNameIndex = 1;
    private const int ConditionClassMessageIndex = 2;

    // #477 — indices in BuildQualityEventFilter's clause [Quality, Message]. Again SEPARATE from the load-bearing
    // ScadaBridge clause so its indices don't shift.
    private const int QualityIndex = 0;
    private const int QualityMessageIndex = 1;

    /// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
    /// Server-object subscriber using the standard BaseEventType select clause.</summary>
    [Fact]
    public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
    {
        var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}");
        var port = AllocateFreePort();
        var ct = TestContext.Current.CancellationToken;
        try
        {
            var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
            await using var _ = host;

            var sink = new SdkAddressSpaceSink(server.NodeManager!);
            WireCondition(sink);

            using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
            // Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
            // expected SourceNode is built exactly as a real client would resolve it.
            var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
            rawNs.ShouldBeGreaterThan((ushort)0);

            var collector = new EventCollector(MessageIndex);
            var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
            subscription.FastEventCallback = collector.OnEvents;
            session.AddSubscription(subscription);
            await subscription.CreateAsync(ct);

            // The collector filters by our unique Message, so the item's ClientHandle is not needed here
            // (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
            AddEventItem(subscription, ObjectIds.Server, BuildEventFilter());
            await subscription.ApplyChangesAsync(ct);

            sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);

            await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
            var fields = collector.Fields.ShouldHaveSingleItem();

            // --- the three fields this issue is about ---
            // EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
            fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
                "EventType must carry the concrete condition type, not null");

            // SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
            fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
                "SourceNode must identify the source condition node, not null");

            // SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
            // name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
            fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
                "SourceName must carry the unique RawPath, not null");
            fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
                "SourceName is deliberately the unique RawPath, not the ambiguous leaf name");

            // The rest of the standard envelope still arrives intact (guards against a regression that
            // populated identity at the cost of the existing fields).
            fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
            ((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
            fields[SeverityIndex].Value.ShouldBe((ushort)700);

            await subscription.DeleteAsync(true, ct);
        }
        finally
        {
            SafeDelete(pkiRoot + "-srv");
        }
    }

    /// <summary>Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName.
    /// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an
    /// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin.</summary>
    [Fact]
    public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire()
    {
        var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}");
        var port = AllocateFreePort();
        var ct = TestContext.Current.CancellationToken;
        try
        {
            var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct);
            await using var _ = host;

            var sink = new SdkAddressSpaceSink(server.NodeManager!);
            WireCondition(sink);

            using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);

            var collector = new EventCollector(ConditionClassMessageIndex);
            var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
            subscription.FastEventCallback = collector.OnEvents;
            session.AddSubscription(subscription);
            await subscription.CreateAsync(ct);

            AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter());
            await subscription.ApplyChangesAsync(ct);

            sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);

            await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
            var fields = collector.Fields.ShouldHaveSingleItem();

            // ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
            // Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
            fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
                "ConditionClassId must carry a resolvable condition class, not null");

            // ConditionClassName — the matching human-readable name, NOT empty text.
            var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
            className.Text.ShouldBe("BaseConditionClass",
                "ConditionClassName must name the reported condition class, not be empty");

            await subscription.DeleteAsync(true, ct);
        }
        finally
        {
            SafeDelete(pkiRoot + "-srv");
        }
    }

    /// <summary>Issue #477 — the over-the-wire proof that a native condition's source-data Quality tracks its
    /// driver's connectivity. A comms-lost source (<c>WriteAlarmQuality(Bad)</c>) delivers a condition event
    /// whose <c>Quality</c> is non-Good, and recovery (<c>WriteAlarmQuality(Good)</c>) delivers Good again — so a
    /// client can distinguish "genuinely inactive" from "we have lost contact". Before the fix the field was the
    /// accidentally-Good default (<c>default(StatusCode) == Good</c>) on every event regardless of the source.</summary>
    [Fact]
    public async Task Condition_event_Quality_tracks_source_connectivity_on_the_wire()
    {
        var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-quality-{Guid.NewGuid():N}");
        var port = AllocateFreePort();
        var ct = TestContext.Current.CancellationToken;
        try
        {
            var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", QualityServerUri, ct);
            await using var _ = host;

            var sink = new SdkAddressSpaceSink(server.NodeManager!);
            WireCondition(sink);

            using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);

            var collector = new EventCollector(QualityMessageIndex);
            var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
            subscription.FastEventCallback = collector.OnEvents;
            session.AddSubscription(subscription);
            await subscription.CreateAsync(ct);

            AddEventItem(subscription, ObjectIds.Server, BuildQualityEventFilter());
            await subscription.ApplyChangesAsync(ct);

            // Raise the alarm while the source is healthy → Quality Good.
            sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
            await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
            StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
                .ShouldBeTrue("a healthy source's condition reports Good quality");

            // Device goes comms-lost (connectivity path) → the next condition event carries a Bad Quality, while
            // the alarm stays active (annotation only — not asserted here, covered by the node-level test).
            sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
            await WaitUntilAsync(() => collector.Fields.Count >= 2, TimeSpan.FromSeconds(5));
            StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].Value)
                .ShouldBeTrue("a comms-lost source's condition must report non-Good quality on the wire");

            // Device recovers → Quality returns to Good.
            sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
            await WaitUntilAsync(() => collector.Fields.Count >= 3, TimeSpan.FromSeconds(5));
            StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
                .ShouldBeTrue("a recovered source's condition reports Good quality again");

            await subscription.DeleteAsync(true, ct);
        }
        finally
        {
            SafeDelete(pkiRoot + "-srv");
        }
    }

    /// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
    /// equipment folder wired as a notifier (the production topology).</summary>
    private static void WireCondition(SdkAddressSpaceSink sink)
    {
        sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
        sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
        sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
        sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
    }

    private static AlarmConditionSnapshot ActiveSnapshot() =>
        new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
            Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);

    private static MonitoredItem AddEventItem(Subscription subscription, NodeId source, EventFilter filter)
    {
        var item = new MonitoredItem(subscription.DefaultItem)
        {
            StartNodeId = source,
            AttributeId = Attributes.EventNotifier,
            Filter = filter,
            QueueSize = 100,
            SamplingInterval = 0,
        };
        subscription.AddItem(item);
        return item;
    }

    /// <summary>ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].</summary>
    private static EventFilter BuildEventFilter()
    {
        var filter = new EventFilter();
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
        return filter;
    }

    /// <summary>#475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on
    /// <c>ConditionType</c>, not BaseEventType, so they must be selected against that type or the server returns no
    /// value for them regardless of the fix. Message rides along solely as the collector's filter key.</summary>
    private static EventFilter BuildConditionClassEventFilter()
    {
        var filter = new EventFilter();
        filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
        filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
        return filter;
    }

    /// <summary>#477's clause: [Quality, Message]. Quality is declared on <c>ConditionType</c>, so it must be
    /// selected against that type. Message rides along as the collector's filter key (a quality-only change still
    /// snapshots the unchanged Message).</summary>
    private static EventFilter BuildQualityEventFilter()
    {
        var filter = new EventFilter();
        filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality);
        filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
        return filter;
    }

    /// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
    /// server events / refresh brackets never count.</summary>
    /// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
    /// with — the two clauses here place it differently, so it cannot be a shared constant.</param>
    private sealed class EventCollector(int messageIndex)
    {
        private readonly object _gate = new();
        private readonly List<VariantCollection> _fields = new();

        public IReadOnlyList<VariantCollection> Fields
        {
            get { lock (_gate) return _fields.ToList(); }
        }

        public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
        {
            lock (_gate)
            {
                foreach (var e in notification.Events)
                {
                    if (e.EventFields.Count > messageIndex &&
                        e.EventFields[messageIndex].Value is LocalizedText lt &&
                        lt.Text == AlarmMessage)
                    {
                        _fields.Add(e.EventFields);
                    }
                }
            }
        }
    }

    private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
        int port, string pkiRoot, string appUri, CancellationToken ct)
    {
        var options = new OpcUaApplicationHostOptions
        {
            ApplicationName = appUri,
            ApplicationUri = appUri,
            OpcUaPort = port,
            PublicHostname = "127.0.0.1",
            PkiStoreRoot = pkiRoot,
            EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
            AutoAcceptUntrustedClientCertificates = true,
        };
        var server = new OtOpcUaSdkServer();
        var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
        await host.StartAsync(server, ct);
        return (server, host);
    }

    private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
    {
        var appConfig = new ApplicationConfiguration
        {
            ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
            ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}",
            ApplicationType = ApplicationType.Client,
            SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
        };
        await appConfig.ValidateAsync(ApplicationType.Client, ct);
        appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;

        var endpoint = await CoreClientUtils.SelectEndpointAsync(
            appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
        var endpointConfiguration = EndpointConfiguration.Create(appConfig);
        var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);

        return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
            appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
            sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
            identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
    }

    private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (DateTime.UtcNow < deadline)
        {
            if (condition()) return;
            await Task.Delay(50);
        }
        condition().ShouldBeTrue("condition not met within timeout");
    }

    private static int AllocateFreePort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    private static void SafeDelete(string dir)
    {
        if (Directory.Exists(dir))
        {
            try { Directory.Delete(dir, recursive: true); }
            catch { /* best-effort */ }
        }
    }
}
