using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;

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

public sealed class ConfigPublishCoordinatorTests : ControlPlaneActorTestBase
{
    private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('a', 64));

    /// <summary>Verifies that deployment seals immediately when no driver-role members are in the cluster.</summary>
    [Fact]
    public void EmptyCluster_dispatch_seals_immediately()
    {
        // With no driver-role cluster members in scope, the coordinator has nobody to wait for
        // and seals the deployment right after writing the AwaitingApplyAcks status.
        var dbFactory = NewInMemoryDbFactory();
        var deploymentId = SeedDispatchingDeployment(dbFactory);

        var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory));
        actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));

        AwaitAssert(() =>
        {
            using var db = dbFactory.CreateDbContext();
            var status = db.Deployments.Single().Status;
            status.ShouldBe(DeploymentStatus.Sealed);
        }, duration: TimeSpan.FromSeconds(3));
    }

    /// <summary>Verifies that stale apply acknowledgments arriving after seal are ignored.</summary>
    [Fact]
    public void Stale_ApplyAck_after_seal_is_ignored()
    {
        var dbFactory = NewInMemoryDbFactory();
        var deploymentId = SeedDispatchingDeployment(dbFactory);
        var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory));

        actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));

        // Wait for seal.
        AwaitAssert(() =>
        {
            using var db = dbFactory.CreateDbContext();
            db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
        }, duration: TimeSpan.FromSeconds(3));

        // Now send a late ApplyAck for the just-sealed deployment. Should be a no-op — neither
        // crash the actor nor modify the row. We give it a beat and re-check the status.
        actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("ghost-node"),
            ApplyAckOutcome.Applied, null, CorrelationId.NewId()));

        ExpectNoMsg(TimeSpan.FromMilliseconds(250));
        using var db = dbFactory.CreateDbContext();
        db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
    }

    /// <summary>
    /// Regression guard for ControlPlane-001: an ApplyAck from a node that was NOT in the expected-ack
    /// set (i.e. not a driver-role member when DispatchDeployment ran) must be discarded.
    /// Without the fix, an unexpected-node ack inflates <c>_acks.Count</c> and can cause the
    /// coordinator to seal a deployment before every expected node has responded.
    ///
    /// Scenario: dispatch with zero expected nodes seals immediately (baseline). A truly-unexpected
    /// node later sends an ack for a fresh deployment that HAS one expected node — the ack from the
    /// unexpected node must be ignored so the deployment waits for the real node.
    /// </summary>
    [Fact]
    public void ApplyAck_from_unexpected_node_is_discarded_and_does_not_seal_prematurely()
    {
        var dbFactory = NewInMemoryDbFactory();
        var deploymentId = SeedDispatchingDeployment(dbFactory);

        // Seed a NodeDeploymentState row for "expected-driver" so the coordinator sees 1 expected ack.
        using (var db = dbFactory.CreateDbContext())
        {
            db.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState
            {
                NodeId = "expected-driver",
                DeploymentId = deploymentId.Value,
                Status = Configuration.Enums.NodeDeploymentStatus.Applying,
            });
            db.SaveChanges();
        }

        // Long deadline so time does not confound the test.
        var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, TimeSpan.FromMinutes(5)));

        // Drive the coordinator into the AwaitingApplyAcks state via DispatchDeployment.
        // The coordinator seeds expected-acks from _cluster.State.Members (filtered to driver role).
        // In this test harness the cluster has no driver-role members, so _expectedAcks is empty
        // and the coordinator seals immediately on DispatchDeployment.
        //
        // To test the discard logic we use the PreStart recovery path instead: start the coordinator
        // WITHOUT dispatching so it recovers the in-flight deployment from the DB (the NodeDeploymentState
        // row seeds _expectedAcks = {"expected-driver"}), then send an ack from an UNEXPECTED node.
        // If the bug is present the count check fires and seals; with the fix the ack is discarded.

        // Send ack from a rogue node NOT in _expectedAcks.
        actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("rogue-node"),
            ApplyAckOutcome.Applied, null, CorrelationId.NewId()));

        // Give it time to process and potentially seal if buggy.
        ExpectNoMsg(TimeSpan.FromMilliseconds(400));

        using var db2 = dbFactory.CreateDbContext();
        var status = db2.Deployments.Single().Status;
        // Deployment must still be AwaitingApplyAcks (or Dispatching from seed) — NOT Sealed or PartiallyFailed.
        status.ShouldNotBe(DeploymentStatus.Sealed);
        status.ShouldNotBe(DeploymentStatus.PartiallyFailed);
    }

    private static DeploymentId SeedDispatchingDeployment(
        Microsoft.EntityFrameworkCore.IDbContextFactory<Configuration.OtOpcUaConfigDbContext> dbFactory)
    {
        var id = DeploymentId.NewId();
        using var db = dbFactory.CreateDbContext();
        db.Deployments.Add(new Configuration.Entities.Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = TestRevision.Value,
            Status = DeploymentStatus.Dispatching,
            CreatedBy = "test",
        });
        db.SaveChanges();
        return id;
    }
}
