using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
{
    /// <summary>
    ///     Subscribes a probe to the cluster <c>alarm-commands</c> topic and waits for the
    ///     <see cref="SubscribeAck"/> so the subscription is live before the actor under test
    ///     publishes. Returns the subscribed probe.
    /// </summary>
    private TestProbe SubscribeAlarmCommandsProbe()
    {
        var probe = CreateTestProbe("alarm-cmds");
        var mediator = DistributedPubSub.Get(Sys).Mediator;
        // Send the Subscribe FROM the probe so the SubscribeAck routes back to it (the ack goes to the
        // message sender, not to the subscribed ref). Mirrors ScriptedAlarmHostActor's self-subscribe.
        probe.Send(mediator, new Subscribe(AlarmCommandsTopic.Name, probe.Ref));
        probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
        return probe;
    }

    /// <summary>Verifies an <see cref="AcknowledgeAlarmCommand"/> publishes a correctly-mapped
    /// <see cref="AlarmCommand"/> (Operation="Acknowledge", AlarmId/User/Comment threaded, no
    /// UnshelveAtUtc) onto the <c>alarm-commands</c> topic and replies Ok.</summary>
    [Fact]
    public void AcknowledgeAlarm_publishes_mapped_command_and_replies_ok()
    {
        var dbFactory = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
        var topicProbe = SubscribeAlarmCommandsProbe();

        var correlationId = CorrelationId.NewId();
        actor.Tell(new AcknowledgeAlarmCommand("alarm-42", "operator-jo", "looking into it", correlationId));

        var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
        published.AlarmId.ShouldBe("alarm-42");
        published.Operation.ShouldBe("Acknowledge");
        published.User.ShouldBe("operator-jo");
        published.Comment.ShouldBe("looking into it");
        published.UnshelveAtUtc.ShouldBeNull();

        var reply = ExpectMsg<AcknowledgeAlarmResult>(TimeSpan.FromSeconds(3));
        reply.Ok.ShouldBeTrue();
        reply.Message.ShouldBeNull();
        reply.CorrelationId.ShouldBe(correlationId);
    }

    /// <summary>Verifies a <see cref="ShelveKind.OneShot"/> shelve maps to Operation="OneShotShelve"
    /// with no UnshelveAtUtc and replies Ok.</summary>
    [Fact]
    public void ShelveAlarm_oneshot_publishes_OneShotShelve()
    {
        var dbFactory = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
        var topicProbe = SubscribeAlarmCommandsProbe();

        var correlationId = CorrelationId.NewId();
        actor.Tell(new ShelveAlarmCommand("alarm-7", "op-kim", ShelveKind.OneShot, UnshelveAtUtc: null, Comment: null, correlationId));

        var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
        published.AlarmId.ShouldBe("alarm-7");
        published.Operation.ShouldBe("OneShotShelve");
        published.User.ShouldBe("op-kim");
        published.UnshelveAtUtc.ShouldBeNull();

        var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
        reply.Ok.ShouldBeTrue();
        reply.CorrelationId.ShouldBe(correlationId);
    }

    /// <summary>Verifies a <see cref="ShelveKind.Timed"/> shelve maps to Operation="TimedShelve" and
    /// threads the UnshelveAtUtc through to the published command.</summary>
    [Fact]
    public void ShelveAlarm_timed_publishes_TimedShelve_with_unshelve_time()
    {
        var dbFactory = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
        var topicProbe = SubscribeAlarmCommandsProbe();

        var unshelveAt = DateTime.UtcNow.AddMinutes(15);
        actor.Tell(new ShelveAlarmCommand("alarm-9", "op-lee", ShelveKind.Timed, unshelveAt, Comment: "maint window", CorrelationId.NewId()));

        var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
        published.AlarmId.ShouldBe("alarm-9");
        published.Operation.ShouldBe("TimedShelve");
        published.UnshelveAtUtc.ShouldBe(unshelveAt);
        published.Comment.ShouldBe("maint window");

        var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
        reply.Ok.ShouldBeTrue();
    }

    /// <summary>Verifies a <see cref="ShelveKind.Unshelve"/> maps to Operation="Unshelve" with no
    /// UnshelveAtUtc and replies Ok.</summary>
    [Fact]
    public void ShelveAlarm_unshelve_publishes_Unshelve()
    {
        var dbFactory = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
        var topicProbe = SubscribeAlarmCommandsProbe();

        actor.Tell(new ShelveAlarmCommand("alarm-3", "op-sam", ShelveKind.Unshelve, UnshelveAtUtc: null, Comment: null, CorrelationId.NewId()));

        var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
        published.Operation.ShouldBe("Unshelve");
        published.UnshelveAtUtc.ShouldBeNull();

        ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3)).Ok.ShouldBeTrue();
    }

    /// <summary>Verifies a <see cref="ShelveKind.Timed"/> shelve with a null UnshelveAtUtc is rejected
    /// at the singleton (no publish) with an attributable failure — the engine requires the instant.</summary>
    [Fact]
    public void ShelveAlarm_timed_without_unshelve_time_is_rejected_and_not_published()
    {
        var dbFactory = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
        var topicProbe = SubscribeAlarmCommandsProbe();

        actor.Tell(new ShelveAlarmCommand("alarm-1", "op-zoe", ShelveKind.Timed, UnshelveAtUtc: null, Comment: null, CorrelationId.NewId()));

        var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
        reply.Ok.ShouldBeFalse();
        reply.Message.ShouldNotBeNull();
        reply.Message.ShouldContain("UnshelveAtUtc");

        // No command should have been published.
        topicProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Verifies that starting a deployment inserts a row and dispatches to the coordinator.</summary>
    [Fact]
    public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator()
    {
        var dbFactory = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        var dispatch = coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
        dispatch.DeploymentId.Value.ShouldNotBe(Guid.Empty);
        dispatch.RevisionHash.Value.Length.ShouldBe(64);

        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
        reply.DeploymentId.ShouldBe(dispatch.DeploymentId);
        reply.RevisionHash.ShouldBe(dispatch.RevisionHash);

        using var db = dbFactory.CreateDbContext();
        var row = db.Deployments.Single();
        row.Status.ShouldBe(DeploymentStatus.Dispatching);
        row.CreatedBy.ShouldBe("joe");
        row.ArtifactBlob.Length.ShouldBeGreaterThan(0);

        db.ConfigEdits.Count().ShouldBe(1);
        db.ConfigEdits.Single().EntityType.ShouldBe("Deployment");
    }

    /// <summary>Verifies the full DraftValidator gate (reject on ANY error): a v3 UNS effective-name
    /// collision — a UnsTagReference (surfacing a raw Tag under an equipment) and a VirtualTag sharing the
    /// same effective leaf name in that equipment's UNS NodeId space — rejects the deploy (422-mapped
    /// <see cref="StartDeploymentOutcome.Rejected"/>) before any coordinator dispatch, inserting no
    /// Deployment row. The colliding equipment uses a canonical EquipmentId so the rejection is
    /// attributable to the collision rule, not to EquipmentIdNotDerived.</summary>
    [Fact]
    public void StartDeployment_rejects_on_UNS_effective_name_collision()
    {
        var uuid = Guid.NewGuid();
        var equipmentId = Configuration.Validation.DraftValidator.DeriveEquipmentId(uuid);

        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            db.Equipment.Add(new Configuration.Entities.Equipment
            {
                EquipmentUuid = uuid,
                EquipmentId = equipmentId,
                Name = "eq",
                UnsLineId = "line-a",
                MachineCode = "m",
            });
            // v3: a raw Tag binds to a Device; it reaches the equipment's UNS space via a UnsTagReference.
            db.DriverInstances.Add(new Configuration.Entities.DriverInstance
            {
                DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}",
            });
            db.Devices.Add(new Configuration.Entities.Device
            {
                DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}",
            });
            db.Tags.Add(new Configuration.Entities.Tag
            {
                TagId = "tag-speed",
                DeviceId = "dev",
                Name = "speed",
                DataType = "Float",
                AccessLevel = TagAccessLevel.Read,
                TagConfig = "{}",
            });
            db.UnsTagReferences.Add(new Configuration.Entities.UnsTagReference
            {
                UnsTagReferenceId = "ref-speed", EquipmentId = equipmentId, TagId = "tag-speed",
            });
            // A VirtualTag whose Name collides with the reference's effective name "speed".
            db.VirtualTags.Add(new Configuration.Entities.VirtualTag
            {
                VirtualTagId = "vtag-speed",
                EquipmentId = equipmentId,
                Name = "speed",
                DataType = "Float",
                ScriptId = "s-1",
            });
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
        reply.Message.ShouldNotBeNull();
        reply.Message.ShouldContain("UnsEffectiveNameCollision");   // the v3 rule's error code
        reply.Message.ShouldContain("collide");                     // the rule's message text

        using var verify = dbFactory.CreateDbContext();
        verify.Deployments.Count().ShouldBe(0);
    }

    /// <summary>Verifies the full gate rejects a config whose Equipment carries a NON-canonical
    /// EquipmentId (not == <c>DraftValidator.DeriveEquipmentId(uuid)</c>): the deploy is
    /// <see cref="StartDeploymentOutcome.Rejected"/> with <c>EquipmentIdNotDerived</c> in the message,
    /// no coordinator dispatch, and no Deployment row. This is the rule the surgical gate used to
    /// let through and the reason the full activation was probed first.</summary>
    [Fact]
    public void StartDeployment_rejects_on_non_canonical_EquipmentId()
    {
        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            db.Equipment.Add(new Configuration.Entities.Equipment
            {
                EquipmentUuid = Guid.NewGuid(),
                EquipmentId = "EQ-operator-typed",   // NOT derived from the UUID
                Name = "rinser-01",
                UnsLineId = "line-a",
                MachineCode = "m",
            });
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
        reply.Message.ShouldNotBeNull();
        reply.Message.ShouldContain("EquipmentIdNotDerived");

        using var verify = dbFactory.CreateDbContext();
        verify.Deployments.Count().ShouldBe(0);
    }

    // RETIRED (v3): the Namespace entity and its cluster-binding rule (BadCrossClusterNamespaceBinding)
    // are deleted — the two OPC UA namespaces (Raw/UNS) are implicit and driver instances no longer bind a
    // Namespace. There is no v3 analog for a cross-cluster namespace binding, so this deploy-gate case is
    // dropped (per the v3 schema migration). Cluster scoping is now exercised via the UNS line→area→cluster
    // attribution covered in DeploymentArtifactTests.

    /// <summary>Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough
    /// (genuinely-compiled) Script rows yields an <see cref="StartDeploymentOutcome.Accepted"/>
    /// deploy whose <see cref="StartDeploymentResult.Message"/> carries a compile-cost advisory.
    /// The guardrail NEVER rejects — it only surfaces the estimated RSS pressure to the operator.</summary>
    [Fact]
    public void StartDeployment_warns_when_many_scripts_will_compile()
    {
        const int n = 10;
        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            for (var i = 0; i < n; i++)
            {
                db.Scripts.Add(new Configuration.Entities.Script
                {
                    ScriptId = $"s-{i}",
                    Name = $"script-{i}",
                    SourceCode = $"return (int)ctx.GetTag(\"a\").Value + {i};",   // distinct, non-passthrough
                    SourceHash = $"hash-{i}",
                });
            }
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        // Still dispatches — the advisory is non-blocking.
        coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));

        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
        reply.Message.ShouldNotBeNull();
        reply.Message.ShouldContain("will compile");
        reply.Message.ShouldContain($"{n} script");   // the distinct compiled count

        using var verify = dbFactory.CreateDbContext();
        verify.Deployments.Count().ShouldBe(1);
    }

    /// <summary>Verifies that passthrough "mirror" scripts (<c>return ctx.GetTag("X").Value;</c>)
    /// compile to ~nothing and therefore do NOT count toward the compile-cost advisory: a deploy
    /// of only passthrough scripts is <see cref="StartDeploymentOutcome.Accepted"/> with no advisory.</summary>
    [Fact]
    public void StartDeployment_passthrough_scripts_do_not_count()
    {
        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            for (var i = 0; i < 5; i++)
            {
                db.Scripts.Add(new Configuration.Entities.Script
                {
                    ScriptId = $"mirror-{i}",
                    Name = $"mirror-{i}",
                    SourceCode = $"return ctx.GetTag(\"tag-{i}\").Value;",   // passthrough mirror — costs ~nothing
                    SourceHash = $"mhash-{i}",
                });
            }
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));

        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
        // No compiled scripts → no advisory emitted.
        (reply.Message is null || !reply.Message.Contains("will compile")).ShouldBeTrue();
    }

    /// <summary>Verifies the advisory counts DISTINCT non-passthrough sources: many rows sharing one
    /// identical source collapse to a single compiled unit (the compile cache keys on source), so the
    /// advisory reports 1 — not the row count.</summary>
    [Fact]
    public void StartDeployment_duplicate_sources_collapse_to_one_compiled_unit()
    {
        const string identical = "return (int)ctx.GetTag(\"a\").Value + 1;";
        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            for (var i = 0; i < 7; i++)
            {
                db.Scripts.Add(new Configuration.Entities.Script
                {
                    ScriptId = $"dup-{i}",
                    Name = $"dup-{i}",
                    SourceCode = identical,                 // same source across all rows
                    SourceHash = "dup-hash",
                });
            }
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));

        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
        reply.Message.ShouldNotBeNull();
        reply.Message.ShouldContain("will compile");
        reply.Message.ShouldContain("1 script(s) will compile");
    }

    /// <summary>Verifies the cluster-topology guard is wired into the deploy gate (Configuration-013):
    /// a <see cref="RedundancyMode.Hot"/> cluster with only ONE enabled <see cref="ClusterNode"/>
    /// (the second toggled off) is <see cref="StartDeploymentOutcome.Rejected"/> with the
    /// <c>ClusterEnabledNodeCountMismatch</c> topology error in the message — no coordinator dispatch,
    /// no Deployment row. The row-level SQL CHECK cannot see the disabled-node flag, so this proves the
    /// managed <see cref="Configuration.Validation.DraftValidator.ValidateClusterTopology"/> guard runs
    /// at deploy time rather than sitting inert.</summary>
    [Fact]
    public void StartDeployment_rejects_on_invalid_cluster_topology_disabled_node()
    {
        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            db.ServerClusters.Add(new Configuration.Entities.ServerCluster
            {
                ClusterId = "LINE3-OPCUA",
                Name = "Line 3",
                Enterprise = "zb",
                Site = "dev",
                NodeCount = 2,
                RedundancyMode = RedundancyMode.Hot,   // declared 2 + Hot, but only 1 enabled below
                CreatedBy = "seed",
            });
            db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
            {
                NodeId = "LINE3-OPCUA-A",
                ClusterId = "LINE3-OPCUA",
                Host = "host-a",
                ApplicationUri = "urn:line3:a",
                Enabled = true,
                CreatedBy = "seed",
            });
            db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
            {
                NodeId = "LINE3-OPCUA-B",
                ClusterId = "LINE3-OPCUA",
                Host = "host-b",
                ApplicationUri = "urn:line3:b",
                Enabled = false,   // toggled off → effective enabled-count = 1 while mode stays Hot
                CreatedBy = "seed",
            });
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
        reply.Message.ShouldNotBeNull();
        reply.Message.ShouldContain("ClusterEnabledNodeCountMismatch");

        using var verify = dbFactory.CreateDbContext();
        verify.Deployments.Count().ShouldBe(0);
    }

    /// <summary>Verifies the topology guard does NOT spuriously reject a well-formed cluster: a
    /// <see cref="RedundancyMode.Hot"/> cluster whose two <see cref="ClusterNode"/>s are both enabled
    /// passes the topology check, so a deploy of an otherwise-valid config is
    /// <see cref="StartDeploymentOutcome.Accepted"/> with no topology error in the message and a row
    /// inserted. Pairs with the rejecting test to prove the guard is discriminating, not blanket.</summary>
    [Fact]
    public void StartDeployment_accepts_when_cluster_topology_is_valid()
    {
        var dbFactory = NewInMemoryDbFactory();
        using (var db = dbFactory.CreateDbContext())
        {
            db.ServerClusters.Add(new Configuration.Entities.ServerCluster
            {
                ClusterId = "LINE3-OPCUA",
                Name = "Line 3",
                Enterprise = "zb",
                Site = "dev",
                NodeCount = 2,
                RedundancyMode = RedundancyMode.Hot,
                CreatedBy = "seed",
            });
            db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
            {
                NodeId = "LINE3-OPCUA-A",
                ClusterId = "LINE3-OPCUA",
                Host = "host-a",
                ApplicationUri = "urn:line3:a",
                Enabled = true,
                CreatedBy = "seed",
            });
            db.ClusterNodes.Add(new Configuration.Entities.ClusterNode
            {
                NodeId = "LINE3-OPCUA-B",
                ClusterId = "LINE3-OPCUA",
                Host = "host-b",
                ApplicationUri = "urn:line3:b",
                Enabled = true,   // both enabled → matches declared NodeCount=2 + Hot
                CreatedBy = "seed",
            });
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));

        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
        (reply.Message is null || !reply.Message.Contains("ClusterEnabledNodeCountMismatch")).ShouldBeTrue();
        (reply.Message is null || !reply.Message.Contains("ClusterRedundancyModeInvalid")).ShouldBeTrue();

        using var verify = dbFactory.CreateDbContext();
        verify.Deployments.Count().ShouldBe(1);
    }

    /// <summary>Verifies that starting a deployment is refused when another is in flight.</summary>
    [Fact]
    public void StartDeployment_refuses_when_another_is_in_flight()
    {
        var dbFactory = NewInMemoryDbFactory();
        // Seed an in-flight Deployment.
        using (var db = dbFactory.CreateDbContext())
        {
            db.Deployments.Add(new Configuration.Entities.Deployment
            {
                RevisionHash = new string('a', 64),
                Status = DeploymentStatus.Dispatching,
                CreatedBy = "earlier",
            });
            db.SaveChanges();
        }

        var coordinator = CreateTestProbe("coord");
        var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));

        actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));

        coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
        reply.Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight);
        reply.DeploymentId.ShouldNotBeNull();
    }
}
