using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;

namespace ZB.MOM.WW.OtOpcUa.Server.Tests;

/// <summary>
///     Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
///     dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
///     opens a client session, raises a driver-side transition, and asserts it propagates
///     through <c>GenericDriverNodeManager</c>'s alarm forwarder into
///     <c>DriverNodeManager.ConditionSink</c>, updates the server-side
///     <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
///     flows out to an OPC UA subscription on the Server object's EventNotifier.
///
///     Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
///     <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
///     integration gap for optional driver capabilities (plan decision #62).
/// </summary>
[Trait("Category", "Integration")]
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
{
    private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
    private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
    private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");

    private DriverHost _driverHost = null!;
    private OpcUaApplicationHost _server = null!;
    private AlarmDriver _driver = null!;

    public async ValueTask InitializeAsync()
    {
        _driverHost = new DriverHost();
        _driver = new AlarmDriver();
        await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);

        var options = new OpcUaServerOptions
        {
            EndpointUrl = _endpoint,
            ApplicationName = "OtOpcUaAlarmTest",
            ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
            PkiStoreRoot = _pkiRoot,
            AutoAcceptUntrustedClientCertificates = true,
            HealthEndpointsEnabled = false,
        };

        _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
            NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
        await _server.StartAsync(CancellationToken.None);
    }

    public async ValueTask DisposeAsync()
    {
        await _server.DisposeAsync();
        await _driverHost.DisposeAsync();
        try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
    }

    [Fact]
    public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
    {
        using var session = await OpenSessionAsync();
        var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");

        _driver.RaiseAlarm(new AlarmEventArgs(
            SubscriptionHandle: new FakeHandle("sub"),
            SourceNodeId: "Tank.HiHi",
            ConditionId: "cond-1",
            AlarmType: "Active",
            Message: "Level exceeded upper-upper",
            Severity: AlarmSeverity.High,
            SourceTimestampUtc: DateTime.UtcNow));

        // The alarm-condition node's identifier is the driver full-reference + ".Condition"
        // (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
        // are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
        // so by the time RaiseAlarm returns the node state has been flushed.
        var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);

        // Browse the condition node for the well-known Part-9 child variables. The stack
        // materializes Severity / Message / ActiveState / AckedState as children below the
        // AlarmConditionState; their NodeIds are allocated by the stack so we discover them
        // by BrowseName rather than guessing.
        var browseDescriptions = new BrowseDescriptionCollection
        {
            new()
            {
                NodeId = conditionNodeId,
                BrowseDirection = BrowseDirection.Forward,
                ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
                IncludeSubtypes = true,
                NodeClassMask = 0,
                ResultMask = (uint)BrowseResultMask.All,
            },
        };
        session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
        var children = browseResults[0].References
            .ToDictionary(r => r.BrowseName.Name,
                r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
                StringComparer.Ordinal);

        children.ShouldContainKey("Severity");
        children.ShouldContainKey("Message");
        children.ShouldContainKey("ActiveState");

        // Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
        // the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
        // landed the new values in addressable child nodes. DriverNodeManager's
        // AssignSymbolicDescendantIds keeps each child reachable under the node manager's
        // namespace so Read resolves against the predefined-node dictionary.
        var severity = session.ReadValue(children["Severity"]);
        var message = session.ReadValue(children["Message"]);
        severity.Value.ShouldBe((ushort)700);  // AlarmSeverity.High → 700 (MapSeverity)
        ((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");

        // ActiveState exposes its boolean Id as a HasProperty child.
        var activeBrowse = new BrowseDescriptionCollection
        {
            new()
            {
                NodeId = children["ActiveState"],
                BrowseDirection = BrowseDirection.Forward,
                ReferenceTypeId = ReferenceTypeIds.HasProperty,
                IncludeSubtypes = true,
                ResultMask = (uint)BrowseResultMask.All,
            },
        };
        session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
        var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
        var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
        activeId.Value.ShouldBe(true);
    }

    [Fact]
    public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
    {
        // AddRootNotifier registers the AlarmConditionState as a Server-object notifier
        // source, so a subscription with an EventFilter on Server receives the
        // ReportEvent calls ConditionSink emits per-transition.
        using var session = await OpenSessionAsync();

        var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
        session.AddSubscription(subscription);
        await subscription.CreateAsync();

        var received = new List<EventFieldList>();
        var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

        var filter = new EventFilter();
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
        filter.WhereClause = new ContentFilter();
        filter.WhereClause.Push(FilterOperator.OfType,
            new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });

        var item = new MonitoredItem(subscription.DefaultItem)
        {
            StartNodeId = ObjectIds.Server,
            AttributeId = Attributes.EventNotifier,
            NodeClass = NodeClass.Object,
            SamplingInterval = 0,
            QueueSize = 100,
            Filter = filter,
        };
        item.Notification += (_, e) =>
        {
            if (e.NotificationValue is EventFieldList fields)
            {
                lock (received) { received.Add(fields); gate.TrySetResult(); }
            }
        };
        subscription.AddItem(item);
        await subscription.ApplyChangesAsync();

        // Give the publish loop a tick to establish before firing.
        await Task.Delay(200);

        _driver.RaiseAlarm(new AlarmEventArgs(
            new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
            "High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));

        var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
        delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");

        EventFieldList first;
        lock (received) first = received[0];
        // Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
        ((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
        first.EventFields[3].Value.ShouldBe((ushort)900);  // Critical → 900
    }

    [Fact]
    public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
    {
        // Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
        // MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
        // a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
        // under the driver full-reference. Browse should show both condition nodes with
        // distinct NodeIds using the FullReference + ".Condition" convention.
        using var session = await OpenSessionAsync();
        var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");

        _driver.RaiseAlarm(new AlarmEventArgs(
            new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
            DateTime.UtcNow));

        var attrs = new ReadValueIdCollection
        {
            new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
            new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
        };
        session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
        results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
        ((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
        ((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
    }

    private async Task<ISession> OpenSessionAsync()
    {
        var cfg = new ApplicationConfiguration
        {
            ApplicationName = "OtOpcUaAlarmTestClient",
            ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
            ApplicationType = ApplicationType.Client,
            SecurityConfiguration = new SecurityConfiguration
            {
                ApplicationCertificate = new CertificateIdentifier
                {
                    StoreType = CertificateStoreType.Directory,
                    StorePath = Path.Combine(_pkiRoot, "client-own"),
                    SubjectName = "CN=OtOpcUaAlarmTestClient",
                },
                TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
                TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
                RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
                AutoAcceptUntrustedCertificates = true,
                AddAppCertToTrustedStore = true,
            },
            TransportConfigurations = new TransportConfigurationCollection(),
            TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
        };
        await cfg.Validate(ApplicationType.Client);
        cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;

        var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
        await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);

        var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
        var endpointConfig = EndpointConfiguration.Create(cfg);
        var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);

        return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
            new UserIdentity(new AnonymousIdentityToken()), null);
    }

    /// <summary>
    ///     Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
    ///     bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
    ///     fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
    /// </summary>
    private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
    {
        public string DriverInstanceId => "alarm-driver";
        public string DriverType => "AlarmStub";

        public event EventHandler<AlarmEventArgs>? OnAlarmEvent;

        public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
        public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
        public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
        public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
        public long GetMemoryFootprint() => 0;
        public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;

        public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
        {
            var tank = builder.Folder("Tank", "Tank");
            var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
                "Tank.HiHi", DriverDataType.Boolean, false, null,
                SecurityClassification.FreeAccess, false, IsAlarm: true));
            hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
                "Tank.HiHi", AlarmSeverity.High, "High-high alarm"));

            var heater = builder.Folder("Heater", "Heater");
            var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
                "Heater.OverTemp", DriverDataType.Boolean, false, null,
                SecurityClassification.FreeAccess, false, IsAlarm: true));
            ot.MarkAsAlarmCondition(new AlarmConditionInfo(
                "Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
            return Task.CompletedTask;
        }

        public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);

        public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
            IReadOnlyList<string> _, CancellationToken __)
            => Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));

        public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
            => Task.CompletedTask;

        public Task AcknowledgeAsync(
            IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
            => Task.CompletedTask;
    }

    private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
    {
        public string DiagnosticId { get; } = diagnosticId;
    }
}
