using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
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.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
{
    /// <summary>Tests that RebuildAddressSpace with dbFactory loads artifact, composes, and applies.</summary>
    [Fact]
    public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies()
    {
        var db = NewInMemoryDbFactory();
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });

        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            sink: sink,
            dbFactory: db,
            applier: applier));

        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));

        AwaitAssert(() =>
        {
            // Add path: Equipment + Driver + Alarm — but only Equipment/Alarm topology triggers
            // RebuildAddressSpace. With 2 new equipment we expect one Rebuild call.
            sink.RebuildCalls.ShouldBe(1);
        }, duration: TimeSpan.FromSeconds(2));
    }

    /// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
    [Fact]
    public void Rebuild_with_no_artifact_is_idempotent_no_op()
    {
        var db = NewInMemoryDbFactory();
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        // No deployment seeded — LoadLatestArtifact returns empty blob.
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            sink: sink,
            dbFactory: db,
            applier: applier));

        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
        Thread.Sleep(200);

        sink.RebuildCalls.ShouldBe(0);
    }

    /// <summary>Tests that second rebuild with same artifact is empty plan no-op.</summary>
    [Fact]
    public void Second_rebuild_with_same_artifact_is_empty_plan_no_op()
    {
        var db = NewInMemoryDbFactory();
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
        SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());

        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            sink: sink, dbFactory: db, applier: applier));

        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
        AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));

        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
        Thread.Sleep(200);
        // Same composition ⇒ plan IsEmpty ⇒ applier not called again.
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>Tests that rebuild without dbFactory falls back to raw sink rebuild.</summary>
    [Fact]
    public void Rebuild_without_dbFactory_falls_back_to_raw_sink_rebuild()
    {
        // Pre-#109 behavior: no dbFactory wired ⇒ RebuildAddressSpace calls _sink.RebuildAddressSpace
        // directly. The dev/Mac path before the full integration is bound.
        var sink = new RecordingSink();
        var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));

        actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));

        AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
    }

    /// <summary>
    ///     Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
    ///     materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
    ///     shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver +
    ///     one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the
    ///     SITE-A tag (<c>t-sa</c> → variable <c>F.S1</c>) and NOT MAIN's (<c>t-main</c> →
    ///     <c>F.M1</c>); the mirror holds for the MAIN node. Without the production scoping edit,
    ///     the unscoped parse would materialise BOTH variables on every node.
    /// </summary>
    [Fact]
    public void Rebuild_materialises_only_the_nodes_cluster()
    {
        // --- SITE-A node: only the SITE-A tag's variable, never MAIN's. ---
        var dbA = NewInMemoryDbFactory();
        var sinkA = new RecordingSink();
        var applierA = new Phase7Applier(sinkA, NullLogger<Phase7Applier>.Instance);
        SeedMultiClusterDeployment(dbA);

        var siteActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            sink: sinkA,
            dbFactory: dbA,
            applier: applierA,
            localNode: NodeId.Parse("site-a-1:4053")));

        siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));

        AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
        // t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1".
        sinkA.Calls.ShouldContain("EV:F.S1");
        // t-main (MAIN cluster) must NOT leak onto the SITE-A node.
        sinkA.Calls.ShouldNotContain("EV:F.M1");

        // --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
        var dbM = NewInMemoryDbFactory();
        var sinkM = new RecordingSink();
        var applierM = new Phase7Applier(sinkM, NullLogger<Phase7Applier>.Instance);
        SeedMultiClusterDeployment(dbM);

        var mainActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
            sink: sinkM,
            dbFactory: dbM,
            applier: applierM,
            localNode: NodeId.Parse("central-1:4053")));

        mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));

        AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
        sinkM.Calls.ShouldContain("EV:F.M1");
        sinkM.Calls.ShouldNotContain("EV:F.S1");
    }

    /// <summary>
    ///     Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster
    ///     shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one SystemPlatform
    ///     namespace + Galaxy driver + Galaxy tag per cluster. Used by
    ///     <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
    /// </summary>
    private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
    {
        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            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 = new[]
            {
                new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN",   NamespaceId = "main-ns" },
                new { DriverInstanceId = "sa-galaxy",   DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
            },
            Namespaces = new[]
            {
                new { NamespaceId = "main-ns", ClusterId = "MAIN",   Kind = 1 }, // NamespaceKind.SystemPlatform
                new { NamespaceId = "sa-ns",   ClusterId = "SITE-A", Kind = 1 },
            },
            Tags = new[]
            {
                new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
                new { TagId = "t-sa",   DriverInstanceId = "sa-galaxy",   EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
            },
            ScriptedAlarms = Array.Empty<object>(),
        });

        using var ctx = dbFactory.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = Guid.NewGuid(),
            RevisionHash = new string('b', 64),
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
    }

    private static void SeedDeployment(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        string[] equipmentIds,
        string[] driverIds)
    {
        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Equipment = equipmentIds.Select(id => new
            {
                EquipmentId = id,
                MachineCode = id.ToUpperInvariant(),
                UnsLineId = "line-1",
                Name = id,
            }).ToArray(),
            DriverInstances = driverIds.Select(id => new
            {
                DriverInstanceId = id,
                DriverType = "Modbus",
                Enabled = true,
                DriverConfig = "{}",
            }).ToArray(),
            ScriptedAlarms = Array.Empty<object>(),
        });

        using var ctx = dbFactory.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = Guid.NewGuid(),
            RevisionHash = new string('a', 64),
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
    }

    private sealed class RecordingSink : IOpcUaAddressSpaceSink
    {
        /// <summary>Gets the list of recorded sink calls.</summary>
        public ConcurrentQueue<string> Calls { get; } = new();
        /// <summary>Gets or sets the count of rebuild address space calls.</summary>
        public int RebuildCalls;
        /// <summary>Records a value write call.</summary>
        /// <param name="nodeId">The OPC UA node ID.</param>
        /// <param name="value">The value to write.</param>
        /// <param name="quality">The OPC UA quality code.</param>
        /// <param name="ts">The timestamp of the write.</param>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
            => Calls.Enqueue($"WV:{nodeId}");
        /// <summary>Records an alarm condition write call.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</param>
        /// <param name="state">The full condition state snapshot.</param>
        /// <param name="ts">The timestamp of the state change.</param>
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts)
            => Calls.Enqueue($"WA:{alarmNodeId}");
        /// <summary>Records a materialise-alarm-condition call.</summary>
        /// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
        /// <param name="equipmentNodeId">The equipment folder node ID.</param>
        /// <param name="displayName">The condition display name.</param>
        /// <param name="alarmType">The domain alarm type.</param>
        /// <param name="severity">The domain severity.</param>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
            => Calls.Enqueue($"MA:{alarmNodeId}");
        /// <summary>Records a folder ensure call.</summary>
        /// <param name="folderNodeId">The folder node ID.</param>
        /// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
        /// <param name="displayName">The display name of the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
            => Calls.Enqueue($"EF:{folderNodeId}");
        /// <summary>Records a variable ensure call.</summary>
        /// <param name="variableNodeId">The variable node ID.</param>
        /// <param name="parentFolderNodeId">The parent folder node ID, or null if this is a root variable.</param>
        /// <param name="displayName">The display name of the variable.</param>
        /// <param name="dataType">The OPC UA built-in type name.</param>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
            => Calls.Enqueue($"EV:{variableNodeId}");
        /// <summary>Records a rebuild address space call.</summary>
        public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
    }
}
