using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;

namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;

/// <summary>
/// Task 10 (opcua-tag-browser): the site-side
/// <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve
/// <see cref="BrowseNodeCommand"/> against the live adapter and surface
/// every browse outcome as a typed <see cref="BrowseFailure"/>. The split is:
/// the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it
/// knows the per-site connection set); everything else lives in the child where
/// the adapter is held — <see cref="BrowseFailureKind.NotBrowsable"/> from the
/// capability check, <see cref="BrowseFailureKind.ConnectionNotConnected"/> /
/// <see cref="BrowseFailureKind.Timeout"/> / <see cref="BrowseFailureKind.ServerError"/>
/// from the adapter call. These tests guard that split.
/// </summary>
public class DataConnectionManagerBrowseHandlerTests : TestKit
{
    private readonly IDataConnectionFactory _factory;
    private readonly ISiteHealthCollector _healthCollector;
    private readonly DataConnectionOptions _options;

    public DataConnectionManagerBrowseHandlerTests()
        : base(@"akka.loglevel = WARNING")
    {
        _factory = Substitute.For<IDataConnectionFactory>();
        _healthCollector = Substitute.For<ISiteHealthCollector>();
        _options = new DataConnectionOptions
        {
            ReconnectInterval = TimeSpan.FromSeconds(30),
            TagResolutionRetryInterval = TimeSpan.FromSeconds(30),
        };
    }

    [Fact]
    public void Unknown_connection_name_returns_ConnectionNotFound()
    {
        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));

        // No CreateConnectionCommand sent — the manager has zero children, so a
        // browse against any name must be rejected with ConnectionNotFound
        // (the manager is the only actor with site-level visibility).
        manager.Tell(new BrowseNodeCommand("unknown-connection", ParentNodeId: null));

        var reply = ExpectMsg<BrowseNodeResult>();
        Assert.NotNull(reply.Failure);
        Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
        Assert.Empty(reply.Children);
    }

    [Fact]
    public void Non_browsable_adapter_returns_NotBrowsable()
    {
        // Bare IDataConnection — no IBrowsableDataConnection. The child actor's
        // capability check must surface this as NotBrowsable.
        var adapter = Substitute.For<IDataConnection>();
        adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        adapter.Status.Returns(ConnectionHealth.Connected);
        _factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);

        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
        manager.Tell(new CreateConnectionCommand(
            "conn-bare", "OpcUa", new Dictionary<string, string>(), null, 3));

        // Give the manager a moment to spawn the child actor. We do not need to
        // wait for Connected — the browse handler runs in all states.
        AwaitCondition(
            () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
            TimeSpan.FromSeconds(2));

        manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null));

        var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
        Assert.NotNull(reply.Failure);
        Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
        Assert.Empty(reply.Children);
    }

    [Fact]
    public void Success_path_returns_mapped_children()
    {
        // Adapter implementing both IDataConnection (so DataConnectionActor can
        // run its lifecycle) AND IBrowsableDataConnection (so the browse handler
        // takes the success path).
        var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);

        var children = new[]
        {
            new BrowseNode("ns=2;s=A", "A", BrowseNodeClass.Variable, HasChildren: false),
            new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object,   HasChildren: true),
        };
        ((IBrowsableDataConnection)adapter)
            .BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new BrowseChildrenResult(children, Truncated: false));

        _factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
            .Returns((IDataConnection)adapter);

        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
        manager.Tell(new CreateConnectionCommand(
            "conn-ok", "OpcUa", new Dictionary<string, string>(), null, 3));

        AwaitCondition(
            () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
            TimeSpan.FromSeconds(2));

        manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null));

        var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
        Assert.Null(reply.Failure);
        Assert.Equal(2, reply.Children.Count);
        Assert.Equal("ns=2;s=A", reply.Children[0].NodeId);
        Assert.Equal("ns=2;s=B", reply.Children[1].NodeId);
        Assert.False(reply.Truncated);
    }

    [Fact]
    public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
    {
        var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);

        ((IBrowsableDataConnection)adapter)
            .BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException<BrowseChildrenResult>(
                new ConnectionNotConnectedException("OPC UA session is not connected.")));

        _factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
            .Returns((IDataConnection)adapter);

        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
        manager.Tell(new CreateConnectionCommand(
            "conn-down", "OpcUa", new Dictionary<string, string>(), null, 3));

        AwaitCondition(
            () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
            TimeSpan.FromSeconds(2));

        manager.Tell(new BrowseNodeCommand("conn-down", ParentNodeId: null));

        var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
        Assert.NotNull(reply.Failure);
        Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
        Assert.Empty(reply.Children);
    }

    [Fact]
    public void NotSupportedException_maps_to_NotBrowsable_carrying_message()
    {
        // A browsable adapter that is connected but whose server/protocol cannot
        // browse (e.g. an MxGateway build predating the BrowseChildren RPC, which
        // answers gRPC Unimplemented) throws NotSupportedException. The actor must
        // surface this as NotBrowsable with the adapter's actionable message
        // carried verbatim — distinct from the capability-check NotBrowsable
        // (non-browsable adapter), which has no message.
        const string reason =
            "The connected MxGateway build does not support hierarchy browsing. "
            + "Update the gateway to a build that implements BrowseChildren, "
            + "or enter the tag reference manually.";

        var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);

        ((IBrowsableDataConnection)adapter)
            .BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException<BrowseChildrenResult>(new NotSupportedException(reason)));

        _factory.Create("MxGateway", Arg.Any<IDictionary<string, string>>())
            .Returns((IDataConnection)adapter);

        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
        manager.Tell(new CreateConnectionCommand(
            "conn-old-gw", "MxGateway", new Dictionary<string, string>(), null, 3));

        AwaitCondition(
            () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
            TimeSpan.FromSeconds(2));

        manager.Tell(new BrowseNodeCommand("conn-old-gw", ParentNodeId: null));

        var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
        Assert.NotNull(reply.Failure);
        Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
        Assert.Equal(reason, reply.Failure.Message);
        Assert.Empty(reply.Children);
    }

    [Fact]
    public void Oversized_child_list_is_capped_to_frame_budget_and_marked_truncated()
    {
        // A level large enough to exceed Akka's 128 KB remote frame must be clipped
        // by the actor BEFORE it crosses the site→central boundary — otherwise the
        // reply is silently discarded and the picker hangs. This guards every
        // protocol's reply, regardless of how the adapter paginates upstream. The
        // adapter here reports Truncated=false; the actor must still truncate purely
        // on serialized size.
        const int byteBudget = 100 * 1024; // mirrors DataConnectionActor.BrowseResultByteBudget
        var bigList = Enumerable.Range(0, 3000)
            .Select(i => new BrowseNode($"ns=2;s=Item{i:D5}", $"Item{i:D5}", BrowseNodeClass.Variable, HasChildren: false))
            .ToArray();

        var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
        ((IBrowsableDataConnection)adapter)
            .BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new BrowseChildrenResult(bigList, Truncated: false));

        _factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
            .Returns((IDataConnection)adapter);

        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
        manager.Tell(new CreateConnectionCommand(
            "conn-big", "OpcUa", new Dictionary<string, string>(), null, 3));

        AwaitCondition(
            () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
            TimeSpan.FromSeconds(2));

        manager.Tell(new BrowseNodeCommand("conn-big", ParentNodeId: null));

        var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
        Assert.Null(reply.Failure);
        Assert.True(reply.Truncated, "an oversized level must be reported as truncated");
        Assert.True(reply.Children.Count > 0, "the cap must still return a usable prefix");
        Assert.True(reply.Children.Count < bigList.Length, "the level must actually be clipped");

        // The kept prefix's estimated serialized size must respect the budget.
        var keptBytes = reply.Children.Sum(n => 64 + n.NodeId.Length + n.DisplayName.Length);
        Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}");
    }

    [Fact]
    public void ContinuationToken_round_trips_through_the_browse_handler()
    {
        // Task B3 (T15): the BrowseNext continuation token must thread through the
        // actor plumbing in both directions — the inbound command's token must reach
        // the adapter, and the adapter's returned token must be surfaced on the reply
        // so the UI can request the next page. We capture the token the adapter is
        // called with and return a fresh one, then assert both halves.
        const string inboundToken = "page-1-cursor";
        const string outboundToken = "page-2-cursor";
        string? receivedToken = "NOT-CALLED";

        var adapter = Substitute.For<IDataConnection, IBrowsableDataConnection>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);

        var children = new[]
        {
            new BrowseNode("ns=2;s=Next", "Next", BrowseNodeClass.Variable, HasChildren: false),
        };
        ((IBrowsableDataConnection)adapter)
            .BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(ci =>
            {
                receivedToken = ci.ArgAt<string?>(1);
                return new BrowseChildrenResult(children, Truncated: true, ContinuationToken: outboundToken);
            });

        _factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
            .Returns((IDataConnection)adapter);

        var manager = Sys.ActorOf(Props.Create(() =>
            new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
        manager.Tell(new CreateConnectionCommand(
            "conn-paged", "OpcUa", new Dictionary<string, string>(), null, 3));

        AwaitCondition(
            () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
            TimeSpan.FromSeconds(2));

        manager.Tell(new BrowseNodeCommand("conn-paged", ParentNodeId: "ns=2;s=Folder", ContinuationToken: inboundToken));

        var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
        Assert.Null(reply.Failure);
        // Inbound token reached the adapter verbatim.
        Assert.Equal(inboundToken, receivedToken);
        // Outbound token from the adapter is surfaced on the reply for the next page.
        Assert.Equal(outboundToken, reply.ContinuationToken);
        Assert.True(reply.Truncated);
        Assert.Single(reply.Children);
    }
}
