using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;

/// <summary>
///     Regression coverage for Driver.OpcUaClient-016: when the caller cancels during
///     the <c>BrowseNextAsync</c> pagination loop inside <c>BrowseRecursiveAsync</c>, the
///     server-side continuation point must be released via
///     <c>BrowseNextAsync(releaseContinuationPoints: true)</c> before the
///     <see cref="OperationCanceledException"/> propagates. Without the release the server
///     retains the cursor open until the session is closed (a resource leak). This is the
///     same pattern the Browser subagent recorded as Driver.OpcUaClient.Browser-002 and
///     noted also exists in the runtime pagination loop.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientContinuationPointReleaseTests
{
    // A fake non-empty continuation point so the pagination loop can be triggered.
    private static readonly byte[] FakeContinuationPoint = [0x01, 0x02, 0x03];

    /// <summary>
    ///     Builds a loose <see cref="ISession"/> mock where:
    ///     <list type="bullet">
    ///       <item><c>BrowseAsync</c> returns a result with one Object-class child and a
    ///         non-empty continuation point so the pagination loop is entered.</item>
    ///       <item><c>BrowseNextAsync(releaseContinuationPoints: false)</c> throws
    ///         <see cref="OperationCanceledException"/> — simulating cancellation mid-page.</item>
    ///       <item><c>BrowseNextAsync(releaseContinuationPoints: true)</c> returns an empty
    ///         response — the release call the fixed driver should issue before rethrowing.</item>
    ///     </list>
    /// </summary>
    private static Mock<ISession> BuildCancellingSessionMock()
    {
        var mock = new Mock<ISession>(MockBehavior.Loose);
        mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
        mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());

        // BrowseAsync — return one Object child plus a non-empty continuation point so
        // the pagination loop is entered. The child node is irrelevant; the loop is
        // what matters.
        var firstRef = new ReferenceDescription
        {
            NodeId = new ExpandedNodeId(new NodeId(1000, 2), "opc.tcp://test"),
            BrowseName = new QualifiedName("TestObject", 2),
            DisplayName = new LocalizedText("TestObject"),
            NodeClass = NodeClass.Object,
        };
        var browseResult = new BrowseResult
        {
            ContinuationPoint = FakeContinuationPoint,
            References = new ReferenceDescriptionCollection { firstRef },
        };
        var browseResp = new BrowseResponse
        {
            ResponseHeader = new ResponseHeader(),
            Results = new BrowseResultCollection { browseResult },
            DiagnosticInfos = new DiagnosticInfoCollection(),
        };
        mock.Setup(s => s.BrowseAsync(
                It.IsAny<RequestHeader>(),
                It.IsAny<ViewDescription>(),
                It.IsAny<uint>(),
                It.IsAny<BrowseDescriptionCollection>(),
                It.IsAny<CancellationToken>()))
            .ReturnsAsync(browseResp);

        // BrowseNextAsync(release=false) — pagination call: throw cancellation.
        mock.Setup(s => s.BrowseNextAsync(
                It.IsAny<RequestHeader>(),
                It.Is<bool>(release => !release),
                It.IsAny<ByteStringCollection>(),
                It.IsAny<CancellationToken>()))
            .ThrowsAsync(new OperationCanceledException("ct cancelled during BrowseNext"));

        // BrowseNextAsync(release=true) — the release call the fixed driver must make.
        // BrowseNextResponse.Results shares BrowseResult (same as BrowseResponse.Results);
        // there is no separate BrowseNextResult type in the OPC UA SDK.
        var emptyReleaseResult = new BrowseResult
        {
            ContinuationPoint = Array.Empty<byte>(),
            References = new ReferenceDescriptionCollection(),
        };
        var releaseResp = new BrowseNextResponse
        {
            ResponseHeader = new ResponseHeader(),
            Results = new BrowseResultCollection { emptyReleaseResult },
            DiagnosticInfos = new DiagnosticInfoCollection(),
        };
        mock.Setup(s => s.BrowseNextAsync(
                It.IsAny<RequestHeader>(),
                It.Is<bool>(release => release),
                It.IsAny<ByteStringCollection>(),
                It.IsAny<CancellationToken>()))
            .ReturnsAsync(releaseResp);

        return mock;
    }

    /// <summary>
    ///     When the caller's <see cref="CancellationToken"/> fires mid-pagination,
    ///     <c>DiscoverAsync</c> must call <c>BrowseNextAsync(releaseContinuationPoints: true)</c>
    ///     before the exception propagates, so the server-side cursor is freed rather than
    ///     held open until session close.
    /// </summary>
    [Fact]
    public async Task DiscoverAsync_releases_continuation_point_when_cancelled_mid_pagination()
    {
        var ct = TestContext.Current.CancellationToken;
        using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-release");

        var sessionMock = BuildCancellingSessionMock();
        drv.SetSessionForTest(sessionMock.Object);

        var builder = new NullAddressSpaceBuilder();

        // DiscoverAsync should throw (OperationCanceledException propagates after release),
        // but the release BrowseNextAsync(release=true) must be called first.
        await Should.ThrowAsync<OperationCanceledException>(async () =>
            await drv.DiscoverAsync(builder, ct));

        // Verify the release call was made exactly once with releaseContinuationPoints=true.
        sessionMock.Verify(s => s.BrowseNextAsync(
            It.IsAny<RequestHeader>(),
            It.Is<bool>(release => release),
            It.IsAny<ByteStringCollection>(),
            It.IsAny<CancellationToken>()),
            Times.Once,
            "DiscoverAsync must release the server-side continuation point on cancellation " +
            "(Driver.OpcUaClient-016 / Browser-002 cross-module finding)");
    }

    /// <summary>
    ///     Transient browse failure (non-cancellation exception) must NOT attempt a release —
    ///     the server already cleaned up (the request never reached the page-fetch state), and
    ///     calling BrowseNext on a bad continuation point would add noise to the server log.
    ///     <c>BrowseRecursiveAsync</c>'s existing catch swallows transient failures silently;
    ///     we should not change that behaviour.
    /// </summary>
    [Fact]
    public async Task DiscoverAsync_does_not_release_continuation_point_on_non_cancel_browse_failure()
    {
        var ct = TestContext.Current.CancellationToken;
        using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease");

        var mock = new Mock<ISession>(MockBehavior.Loose);
        mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
        mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());

        // BrowseAsync — return a result with a continuation point so the loop is entered.
        var firstRef = new ReferenceDescription
        {
            NodeId = new ExpandedNodeId(new NodeId(1001, 2), "opc.tcp://test"),
            BrowseName = new QualifiedName("TestObj2", 2),
            DisplayName = new LocalizedText("TestObj2"),
            NodeClass = NodeClass.Object,
        };
        var browseResult = new BrowseResult
        {
            ContinuationPoint = FakeContinuationPoint,
            References = new ReferenceDescriptionCollection { firstRef },
        };
        mock.Setup(s => s.BrowseAsync(
                It.IsAny<RequestHeader>(),
                It.IsAny<ViewDescription>(),
                It.IsAny<uint>(),
                It.IsAny<BrowseDescriptionCollection>(),
                It.IsAny<CancellationToken>()))
            .ReturnsAsync(new BrowseResponse
            {
                ResponseHeader = new ResponseHeader(),
                Results = new BrowseResultCollection { browseResult },
                DiagnosticInfos = new DiagnosticInfoCollection(),
            });

        // BrowseNextAsync — throw a non-cancellation transport error.
        mock.Setup(s => s.BrowseNextAsync(
                It.IsAny<RequestHeader>(),
                It.Is<bool>(release => !release),
                It.IsAny<ByteStringCollection>(),
                It.IsAny<CancellationToken>()))
            .ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError));

        drv.SetSessionForTest(mock.Object);

        // DiscoverAsync catches the transient failure and continues (returns normally since
        // the root sub-tree failure just skips that branch).
        await drv.DiscoverAsync(new NullAddressSpaceBuilder(), ct);

        // The release call must NOT be made for a transport error — only for cancellation.
        mock.Verify(s => s.BrowseNextAsync(
            It.IsAny<RequestHeader>(),
            It.Is<bool>(release => release),
            It.IsAny<ByteStringCollection>(),
            It.IsAny<CancellationToken>()),
            Times.Never,
            "A transient transport failure must not trigger a release BrowseNext call");
    }

    /// <summary>Minimal no-op address space builder for discovery tests.</summary>
    private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
    {
        /// <inheritdoc />
        public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;

        /// <inheritdoc />
        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
            => new StubHandle();

        /// <inheritdoc />
        public void AddProperty(string browseName, DriverDataType dataType, object? value) { }

        /// <inheritdoc />
        public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }

        private sealed class StubHandle : IVariableHandle
        {
            public string FullReference => "stub";
            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
        }
    }
}
