using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
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.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;

public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("driver-test");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
    private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));

    /// <summary>Verifies that applying a deployment with driver instances spawns one child per enabled row.</summary>
    [Fact]
    public void Apply_with_driver_instances_in_artifact_spawns_one_child_per_enabled_row()
    {
        var db = NewInMemoryDbFactory();
        var factory = new CountingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithDrivers(db, RevA,
            ("DI-1", "Modbus", "{}", true),
            ("DI-2", "Modbus", "{}", true),
            ("DI-3", "Modbus", "{}", false)); // disabled — not spawned

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" }));

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

        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3));
    }

    /// <summary>Verifies that applying a deployment with unsupported driver type falls back to stub.</summary>
    [Fact]
    public void Apply_with_unsupported_driver_type_falls_back_to_stub()
    {
        var db = NewInMemoryDbFactory();
        // Factory only supports "Modbus" — the Galaxy row should boot stubbed.
        var factory = new CountingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithDrivers(db, RevA,
            ("DI-galaxy", "Galaxy", "{}", true));

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" }));

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

        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // No real driver was constructed — stubbing took over.
        factory.CreateCount.ShouldBe(0);

        // GetDiagnostics should still report the (stubbed) child.
        actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
        var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
        snap.Drivers.Count.ShouldBe(1);
        snap.Drivers[0].State.ShouldBe("Stubbed");
    }

    /// <summary>Verifies that Galaxy driver on non-Windows is stubbed by ShouldStub check.</summary>
    [Fact]
    public void Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check()
    {
        // Even if the factory could create it, ShouldStub('Galaxy', ...) returns true on macOS/Linux —
        // the factory should never be called.
        var db = NewInMemoryDbFactory();
        var factory = new CountingDriverFactory("Galaxy");
        var deploymentId = SeedDeploymentWithDrivers(db, RevA,
            ("DI-galaxy", "Galaxy", "{}", true));

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" }));

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

        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));

        if (OperatingSystem.IsWindows())
        {
            factory.CreateCount.ShouldBe(1);
        }
        else
        {
            factory.CreateCount.ShouldBe(0); // ShouldStub forced the stub path
        }
    }

    /// <summary>Verifies that a second apply with removed driver stops the child.</summary>
    [Fact]
    public void Second_apply_with_removed_driver_stops_the_child()
    {
        var db = NewInMemoryDbFactory();
        var factory = new CountingDriverFactory("Modbus");
        var d1 = SeedDeploymentWithDrivers(db, RevA, ("DI-1", "Modbus", "{}", true), ("DI-2", "Modbus", "{}", true));
        var d2 = SeedDeploymentWithDrivers(db, RevB, ("DI-1", "Modbus", "{}", true));

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" }));

        actor.Tell(new DispatchDeployment(d1, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));
        AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3));

        actor.Tell(new DispatchDeployment(d2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));

        actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
        var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
        snap.Drivers.Count.ShouldBe(1);
    }

    /// <summary>
    ///     Verifies per-ClusterId scoping at the actor level: a 2-cluster artifact (MAIN + SITE-A,
    ///     one driver each) dispatched to a node whose ClusterNode row puts it in SITE-A spawns ONLY
    ///     the SITE-A driver — and the node still reaches Applied (the ack fires unconditionally even
    ///     when a node's cluster slice is empty).
    /// </summary>
    [Fact]
    public void DriverHostActor_spawns_only_its_clusters_drivers()
    {
        var db = NewInMemoryDbFactory();
        var factory = new CountingDriverFactory("Modbus");
        // Both drivers are Modbus so the factory could create either — scoping, not type support,
        // must be what excludes the MAIN driver.
        var deploymentId = SeedMultiClusterDeployment(db, RevA,
            ("main-modbus", "Modbus", "{}", true, "MAIN"),
            ("sa-modbus", "Modbus", "{}", true, "SITE-A"));

        // This node belongs to SITE-A per the Nodes (ClusterNode) rows.
        var siteANode = NodeId.Parse("site-a-1:4053");
        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, siteANode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" }));

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

        // The node still reaches Applied even though it hosts only its own cluster's slice.
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // Only the SITE-A driver was constructed — the MAIN driver was filtered out by scoping.
        AwaitAssert(() => factory.CreateCount.ShouldBe(1), duration: TimeSpan.FromSeconds(3));

        actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
        var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
        snap.Drivers.Count.ShouldBe(1);
        snap.Drivers[0].Name.ShouldBe("sa-modbus");
    }

    /// <summary>
    ///     Regression for the Restart-driver "actor name is not unique" bug: <c>HandleRestartDriver</c>
    ///     stops the old child and respawns from the same spec WITHIN ONE synchronous message handler.
    ///     Akka frees a stopped actor's name only AFTER it fully terminates (async, on the child's own
    ///     mailbox), so the old child is still registered when the respawn runs — reusing the base actor
    ///     name threw <see cref="InvalidActorNameException"/> deterministically on every restart, crashing
    ///     + restarting the host. The fix gives every spawn a fresh generation-suffixed name. Here:
    ///     deploy a Modbus driver, restart it, and assert no such exception is logged and the child is
    ///     cleanly respawned (factory re-created exactly once; one child remains).
    /// </summary>
    [Fact]
    public void RestartDriver_respawns_the_child_without_an_actor_name_collision()
    {
        var db = NewInMemoryDbFactory();
        var factory = new CountingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithDrivers(db, RevA, ("DI-1", "Modbus", "{}", true));

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" }));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        AwaitAssert(() => factory.CreateCount.ShouldBe(1), duration: TimeSpan.FromSeconds(3));

        // The restart must NOT throw InvalidActorNameException (which the buggy in-handler respawn did,
        // crashing + restarting the host). Expect(0, …) asserts zero such logged events across a
        // synchronous round-trip that guarantees the restart is fully processed.
        EventFilter.Exception<InvalidActorNameException>().Expect(0, () =>
        {
            actor.Tell(new RestartDriver("MAIN", "DI-1", "tester", Guid.NewGuid()));
            actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
            coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(3));
        });

        // The respawn re-created the driver under a fresh name — exactly once more (1 → 2), proving the
        // restart completed in-handler rather than throwing — and exactly one child remains.
        AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3));
        actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
        coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2))
            .Drivers.Count.ShouldBe(1);
    }

    private static DeploymentId SeedMultiClusterDeployment(
        IDbContextFactory<OtOpcUaConfigDbContext> db,
        RevisionHash rev,
        params (string Id, string Type, string Config, bool Enabled, string ClusterId)[] drivers)
    {
        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            // >1 cluster + matching Nodes rows triggers ScopeTo (single-cluster would resolve to None).
            Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
            Nodes = new[]
            {
                new { NodeId = "central-1:4053", ClusterId = "MAIN" },
                new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
            },
            DriverInstances = drivers.Select(d => new
            {
                DriverInstanceRowId = Guid.NewGuid(),
                DriverInstanceId = d.Id,
                Name = d.Id,
                DriverType = d.Type,
                Enabled = d.Enabled,
                DriverConfig = d.Config,
                ClusterId = d.ClusterId,
            }).ToArray(),
        });

        var id = DeploymentId.NewId();
        using var ctx = db.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = rev.Value,
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
        return id;
    }

    private static DeploymentId SeedDeploymentWithDrivers(
        IDbContextFactory<OtOpcUaConfigDbContext> db,
        RevisionHash rev,
        params (string Id, string Type, string Config, bool Enabled)[] drivers)
    {
        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            DriverInstances = drivers.Select(d => new
            {
                DriverInstanceRowId = Guid.NewGuid(),
                DriverInstanceId = d.Id,
                Name = d.Id,
                DriverType = d.Type,
                Enabled = d.Enabled,
                DriverConfig = d.Config,
            }).ToArray(),
        });

        var id = DeploymentId.NewId();
        using var ctx = db.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = rev.Value,
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
        return id;
    }

    /// <summary>Test double for IDriverFactory that counts driver creation attempts.</summary>
    private sealed class CountingDriverFactory : IDriverFactory
    {
        private readonly string _supportedType;
        /// <summary>Gets the number of times TryCreate was called and returned a driver.</summary>
        public int CreateCount;
        /// <summary>Initializes a new instance with the specified supported driver type.</summary>
        /// <param name="supportedType">The driver type this factory supports.</param>
        public CountingDriverFactory(string supportedType) { _supportedType = supportedType; }

        /// <summary>Attempts to create a driver if the type is supported.</summary>
        /// <param name="driverType">The driver type to create.</param>
        /// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
        /// <param name="driverConfigJson">The driver configuration in JSON format.</param>
        public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
        {
            if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
            Interlocked.Increment(ref CreateCount);
            return new TestDriver(driverInstanceId, driverType);
        }

        /// <inheritdoc />
        public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
    }

    /// <summary>Test double for IDriver with minimal implementation.</summary>
    private sealed class TestDriver : IDriver
    {
        /// <inheritdoc />
        public string DriverInstanceId { get; }
        /// <inheritdoc />
        public string DriverType { get; }
        /// <summary>Initializes a new test driver with the specified ID and type.</summary>
        /// <param name="id">The driver instance ID.</param>
        /// <param name="type">The driver type.</param>
        public TestDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
        /// <inheritdoc />
        public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
        /// <inheritdoc />
        public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
        /// <inheritdoc />
        public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
        /// <inheritdoc />
        public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
        /// <inheritdoc />
        public long GetMemoryFootprint() => 0;
        /// <inheritdoc />
        public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }
}
