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 B5 (T15): the site-side <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve a
/// <see cref="SearchAddressSpaceCommand"/> against the live adapter and surface
/// every search outcome as a typed <see cref="BrowseFailure"/> (the search path
/// reuses the browse failure kinds). The split mirrors browse exactly: the
/// manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it knows
/// the per-site connection set); the child owns the capability check
/// (<see cref="BrowseFailureKind.NotBrowsable"/> when the adapter is not
/// <see cref="IAddressSpaceSearchable"/>) and the adapter-call failures
/// (<see cref="BrowseFailureKind.ConnectionNotConnected"/> /
/// <see cref="BrowseFailureKind.Timeout"/> / <see cref="BrowseFailureKind.ServerError"/>).
/// </summary>
public class DataConnectionManagerSearchHandlerTests : TestKit
{
    private readonly IDataConnectionFactory _factory;
    private readonly ISiteHealthCollector _healthCollector;
    private readonly DataConnectionOptions _options;

    public DataConnectionManagerSearchHandlerTests()
        : 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
        // search against any name must be rejected with ConnectionNotFound (the
        // manager is the only actor with site-level visibility).
        manager.Tell(new SearchAddressSpaceCommand("unknown-connection", "Tag", 5, 100));

        var reply = ExpectMsg<SearchAddressSpaceResult>();
        Assert.NotNull(reply.Failure);
        Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
        Assert.Empty(reply.Matches);
        Assert.False(reply.CapReached);
    }

    [Fact]
    public void Non_searchable_adapter_returns_NotBrowsable()
    {
        // Bare IDataConnection — no IAddressSpaceSearchable. 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));

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

        manager.Tell(new SearchAddressSpaceCommand("conn-bare", "Tag", 5, 100));

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

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

        var matches = new[]
        {
            new AddressSpaceMatch(
                new BrowseNode("ns=2;s=A", "TagA", BrowseNodeClass.Variable, HasChildren: false),
                "/Folder1/TagA"),
            new AddressSpaceMatch(
                new BrowseNode("ns=2;s=B", "TagB", BrowseNodeClass.Variable, HasChildren: false),
                "/Folder2/TagB"),
        };
        ((IAddressSpaceSearchable)adapter)
            .SearchAddressSpaceAsync("Tag", Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(new AddressSpaceSearchResult(matches, CapReached: true));

        _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 SearchAddressSpaceCommand("conn-ok", "Tag", 5, 100));

        var reply = ExpectMsg<SearchAddressSpaceResult>(TimeSpan.FromSeconds(3));
        Assert.Null(reply.Failure);
        Assert.Equal(2, reply.Matches.Count);
        Assert.Equal("ns=2;s=A", reply.Matches[0].Node.NodeId);
        Assert.Equal("/Folder1/TagA", reply.Matches[0].Path);
        Assert.Equal("ns=2;s=B", reply.Matches[1].Node.NodeId);
        Assert.True(reply.CapReached);
    }

    [Fact]
    public void Query_depth_and_results_thread_through_to_the_adapter()
    {
        // The command's Query/MaxDepth/MaxResults must reach the adapter verbatim.
        string? receivedQuery = "NOT-CALLED";
        int receivedDepth = -1;
        int receivedResults = -1;

        var adapter = Substitute.For<IDataConnection, IAddressSpaceSearchable>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
        ((IAddressSpaceSearchable)adapter)
            .SearchAddressSpaceAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(ci =>
            {
                receivedQuery = ci.ArgAt<string>(0);
                receivedDepth = ci.ArgAt<int>(1);
                receivedResults = ci.ArgAt<int>(2);
                return new AddressSpaceSearchResult(Array.Empty<AddressSpaceMatch>(), CapReached: 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-args", "OpcUa", new Dictionary<string, string>(), null, 3));

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

        manager.Tell(new SearchAddressSpaceCommand("conn-args", "Boiler", 7, 250));

        var reply = ExpectMsg<SearchAddressSpaceResult>(TimeSpan.FromSeconds(3));
        Assert.Null(reply.Failure);
        Assert.Equal("Boiler", receivedQuery);
        Assert.Equal(7, receivedDepth);
        Assert.Equal(250, receivedResults);
        Assert.Empty(reply.Matches);
    }

    [Fact]
    public void ConnectionNotConnectedException_maps_to_ConnectionNotConnected()
    {
        var adapter = Substitute.For<IDataConnection, IAddressSpaceSearchable>();
        ((IDataConnection)adapter).ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
        ((IAddressSpaceSearchable)adapter)
            .SearchAddressSpaceAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException<AddressSpaceSearchResult>(
                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 SearchAddressSpaceCommand("conn-down", "Tag", 5, 100));

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