using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;

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

/// <summary>
///     Phase C Task 4 — the node-manager's OPC UA HistoryReadEvents override over equipment-folder
///     event-notifier nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness Task 3 uses),
///     wires a recording fake <see cref="IHistorianDataSource"/> BEFORE materialising an alarm condition
///     (so the alarm-owning equipment folder is promoted to an event notifier WITH the HistoryRead bit),
///     then invokes the node manager's PUBLIC <c>HistoryRead(OperationContext, …)</c> with a
///     <see cref="ReadEventDetails"/>. The base CustomNodeManager2 builds the node handles + dispatches to
///     the protected <c>HistoryReadEvents</c> override, so this exercises the real dispatch path
///     in-process — fast + deterministic, no client socket.
/// </summary>
public sealed class NodeManagerHistoryReadEventsTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    private readonly string _pkiRoot = Path.Combine(
        Path.GetTempPath(),
        $"otopcua-historyreadevents-{Guid.NewGuid():N}");

    /// <summary>Happy path: the fake receives (sourceName == the equipment-folder id, StartTime, EndTime,
    /// maxEvents), and each returned event decodes to a HistoryEventFieldList whose EventFields are in
    /// SelectClause ORDER with correctly-typed Variants (EventId ByteString, SourceName string, Time
    /// DateTime, ReceiveTime DateTime, Message LocalizedText, Severity UInt16). StatusCode is Good when
    /// events are present.</summary>
    [Fact]
    public async Task Events_dispatches_to_source_and_projects_fields_in_select_order()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource();
        // Wire the source BEFORE materialising the alarm so the folder gets the HistoryRead bit.
        nm.HistorianDataSource = fake;

        const string equipmentId = "eq-evt";
        nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;

        var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
        var rcvTime = new DateTime(2026, 6, 14, 10, 0, 1, DateTimeKind.Utc);
        fake.EventsResult = new HistoricalEventsResult(
            new[] { new HistoricalEvent("evt-42", "Pump_001", evtTime, rcvTime, "Pump tripped", 700) }, null);

        var start = DateTime.UtcNow.AddHours(-1);
        var end = DateTime.UtcNow;
        var details = new ReadEventDetails
        {
            StartTime = start,
            EndTime = end,
            NumValuesPerNode = 50,
            // ReceiveTime is included to verify it projects evt.ReceivedTimeUtc at index 3.
            Filter = SelectFilter("EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"),
        };

        var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);

        // The source saw the equipment/folder id as the sourceName + the request window + cap.
        fake.LastSourceName.ShouldBe(equipmentId);
        fake.LastStart.ShouldBe(start);
        fake.LastEnd.ShouldBe(end);
        fake.LastMaxEvents.ShouldBe(50);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);

        var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
        history.Events.Count.ShouldBe(1);
        var fields = history.Events[0].EventFields;
        fields.Count.ShouldBe(6);

        // EventId ⇒ ByteString (UTF-8 of "evt-42").
        fields[0].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
        // SourceName ⇒ string.
        fields[1].Value.ShouldBe("Pump_001");
        // Time ⇒ DateTime (event occurrence time).
        fields[2].Value.ShouldBe(evtTime);
        // ReceiveTime ⇒ DateTime (server receipt time = ReceivedTimeUtc).
        fields[3].Value.ShouldBe(rcvTime);
        // Message ⇒ LocalizedText.
        fields[4].Value.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
        // Severity ⇒ UInt16.
        fields[5].Value.ShouldBe((ushort)700);

        await host.DisposeAsync();
    }

    /// <summary>OpcUaServer-002: a HistoryReadEvents with <c>NumValuesPerNode == 0</c> means "no limit —
    /// return ALL values" per OPC UA Part 4/11, so the backend must receive an UNBOUNDED cap
    /// (<see cref="int.MaxValue"/>), NOT the <c>maxEvents &lt;= 0</c> "use the default cap" sentinel that
    /// would silently truncate a whole-window read.</summary>
    [Fact]
    public async Task Events_unbounded_request_passes_int_max_to_backend()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource();
        nm.HistorianDataSource = fake;

        const string equipmentId = "eq-unbounded";
        nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns);
        var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;

        fake.EventsResult = new HistoricalEventsResult(
            new[] { new HistoricalEvent("evt-x", "Src", DateTime.UtcNow, DateTime.UtcNow, "msg", 600) }, null);

        var details = new ReadEventDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            // 0 ⇒ "no limit" — the override must translate this to int.MaxValue for the backend.
            NumValuesPerNode = 0,
            Filter = SelectFilter("EventId"),
        };

        var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        // The backend saw the unbounded cap, NOT the 0/default-cap sentinel.
        fake.LastMaxEvents.ShouldBe(int.MaxValue);

        await host.DisposeAsync();
    }

    /// <summary>An unsupported select operand (BrowsePath ["EventType"]) projects to Variant.Null — a field
    /// the server can't supply is null (spec-conformant) — while supported siblings still project.</summary>
    [Fact]
    public async Task Events_unsupported_select_field_projects_null()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource();
        nm.HistorianDataSource = fake;

        const string equipmentId = "eq-unsupported";
        nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
        var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;

        fake.EventsResult = new HistoricalEventsResult(
            new[] { new HistoricalEvent("evt-1", "Src", DateTime.UtcNow, DateTime.UtcNow, "msg", 500) }, null);

        var details = new ReadEventDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            // EventType is a real BaseEventType field we cannot supply from a HistoricalEvent; EventId is.
            Filter = SelectFilter("EventType", "EventId"),
        };

        var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
        var fields = history.Events[0].EventFields;
        fields.Count.ShouldBe(2);
        // EventType ⇒ Variant.Null.
        fields[0].Value.ShouldBeNull();
        // EventId still projects to a ByteString.
        fields[1].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));

        await host.DisposeAsync();
    }

    /// <summary>Empty events ⇒ the notifier's StatusCode is GoodNoData (the source is wired, the window
    /// just held no events).</summary>
    [Fact]
    public async Task Events_empty_yields_GoodNoData()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource
        {
            EventsResult = new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null),
        };
        nm.HistorianDataSource = fake;

        const string equipmentId = "eq-empty";
        nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
        var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;

        var details = new ReadEventDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            Filter = SelectFilter("EventId"),
        };

        var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
        results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
        var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
        history.Events.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>A folder promoted to an event notifier while NO historian was wired (Null source at
    /// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields
    /// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked.</summary>
    [Fact]
    public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        // Materialise the alarm while the source is still the Null default — the folder is promoted to
        // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
        const string equipmentId = "eq-nosrc";
        nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
        var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;

        // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
        var fake = new RecordingHistorianDataSource();
        nm.HistorianDataSource = fake;

        var details = new ReadEventDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            Filter = SelectFilter("EventId"),
        };

        var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        fake.LastSourceName.ShouldBeNull(); // source never reached
        fake.EventsCalled.ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>A variable node targeted by a HistoryReadEvents request ⇒ BadHistoryOperationUnsupported;
    /// the source is never invoked.
    /// <para>
    ///     NOTE — what this test actually pins: the SDK base (CustomNodeManager2.HistoryRead) filters
    ///     event-history reads by the <c>EventNotifier.HistoryRead</c> bit, NOT by
    ///     <c>AccessLevel.HistoryRead</c>. A variable node carries <c>AccessLevel.HistoryRead</c> (for
    ///     variable-history reads) but <c>EventNotifier = None</c> (no event-notifier bits at all). The
    ///     SDK base therefore rejects it and does NOT pass it to our <c>HistoryReadEvents</c> override;
    ///     the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins
    ///     that base-level rejection of variable nodes for event reads.
    ///     The override's own source-guard (miss in <c>_eventNotifierSources</c>) is exercised by the
    ///     <c>Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported</c> test instead.
    /// </para>
    /// </summary>
    [Fact]
    public async Task Events_non_source_node_yields_BadHistoryOperationUnsupported()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource();
        nm.HistorianDataSource = fake;

        // A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but
        // EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs.
        nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
            writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
        var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;

        var details = new ReadEventDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            Filter = SelectFilter("EventId"),
        };

        var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);

        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        fake.EventsCalled.ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>A backend that throws ⇒ that node's error is Bad and no exception escapes the
    /// HistoryRead call. The fake source MUST be invoked (proving we reached the bridge) and threw;
    /// the production catch now sets BOTH errors and results explicitly, so both are asserted here
    /// rather than relying on the SDK base pre-seeding results[i].</summary>
    [Fact]
    public async Task Events_backend_throw_yields_bad_status_and_does_not_escape()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
        nm.HistorianDataSource = fake;

        const string equipmentId = "eq-boom";
        nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns);
        var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;

        var details = new ReadEventDetails
        {
            StartTime = DateTime.UtcNow.AddHours(-1),
            EndTime = DateTime.UtcNow,
            NumValuesPerNode = 10,
            Filter = SelectFilter("EventId"),
        };

        // The call must not throw even though the backend does.
        var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);

        // Authoritative per-node signal: errors[0] must be Bad.
        ServiceResult.IsBad(errors[0]).ShouldBeTrue();
        errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        // The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding).
        results[0].ShouldNotBeNull();
        results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
        // The source WAS entered (proving the override reached the bridge and the throw was swallowed).
        fake.EventsEntered.ShouldBeTrue();
        // ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path.
        fake.EventsCalled.ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>Build a HistoryReadEvents-style event filter whose select clauses are single-element
    /// BrowsePaths over the supplied BaseEventType leaf field names, in order.</summary>
    private static EventFilter SelectFilter(params string[] leafFieldNames)
    {
        var filter = new EventFilter();
        foreach (var name in leafFieldNames)
        {
            filter.SelectClauses.Add(
                new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name))
                {
                    AttributeId = Attributes.Value,
                });
        }

        return filter;
    }

    /// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
    /// results + errors — same session-less <see cref="OperationContext"/> pattern as Task 3's tests.</summary>
    private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
        OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
    {
        var context = new OperationContext(
            new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);

        var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId } };
        var results = new List<SdkHistoryReadResult> { null! };
        var errors = new List<ServiceResult> { null! };

        nm.HistoryRead(
            context,
            details,
            TimestampsToReturn.Both,
            releaseContinuationPoints: false,
            nodesToRead,
            results,
            errors);

        return (results, errors);
    }

    /// <summary>A recording fake historian source — captures the last ReadEventsAsync call's arguments and
    /// returns a configured result (or throws when <see cref="ThrowOnRead"/> is set). The Raw/Processed/
    /// AtTime reads delegate to the Null source (unused by these tests).</summary>
    private sealed class RecordingHistorianDataSource : IHistorianDataSource
    {
        public bool ThrowOnRead { get; init; }
        public HistoricalEventsResult EventsResult { get; set; } =
            new(Array.Empty<HistoricalEvent>(), null);

        /// <summary>Set on every ReadEventsAsync entry, even when ThrowOnRead causes it to throw
        /// before EventsCalled is set — proves the override reached the bridge.</summary>
        public bool EventsEntered { get; private set; }
        public bool EventsCalled { get; private set; }
        public string? LastSourceName { get; private set; }
        public DateTime LastStart { get; private set; }
        public DateTime LastEnd { get; private set; }
        public int LastMaxEvents { get; private set; }

        public Task<HistorianRead> ReadRawAsync(
            string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
            CancellationToken cancellationToken) =>
            NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);

        public Task<HistorianRead> ReadProcessedAsync(
            string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
            HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
            NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);

        public Task<HistorianRead> ReadAtTimeAsync(
            string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
            NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);

        public Task<HistoricalEventsResult> ReadEventsAsync(
            string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
            CancellationToken cancellationToken)
        {
            EventsEntered = true;
            if (ThrowOnRead) throw new InvalidOperationException("backend boom for events");
            EventsCalled = true;
            LastSourceName = sourceName;
            LastStart = startUtc;
            LastEnd = endUtc;
            LastMaxEvents = maxEvents;
            return Task.FromResult(EventsResult);
        }

        public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();

        public void Dispose()
        {
        }
    }

    private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
    {
        var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.HistoryReadEventsTest",
                ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsTest:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

        var server = new OtOpcUaSdkServer();
        await host.StartAsync(server, Ct);
        return (host, server);
    }

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

    /// <summary>Cleans up the PKI root directory.</summary>
    public void Dispose()
    {
        if (Directory.Exists(_pkiRoot))
        {
            try { Directory.Delete(_pkiRoot, recursive: true); }
            catch { /* best-effort cleanup */ }
        }
    }
}
