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.Messages.Deploy;
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.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

/// <summary>
///     Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the
///     served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites
///     (which wire the OPC UA publish side as a <see cref="Akka.TestKit.TestProbe"/> and assert on the
///     intercepted <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> /
///     <see cref="OpcUaPublishActor.AttributeValueUpdate"/> messages), this suite wires the FULL real chain:
///
///     <list type="bullet">
///       <item>a real <see cref="DriverHostActor"/> (resolves equipment, maps via
///       <see cref="DiscoveredNodeMapper"/>, extends the live-value routing map, caches the plan);</item>
///       <item>a real <see cref="OpcUaPublishActor"/> as its <c>opcUaPublishActor</c> seam (so
///       <c>MaterialiseDiscoveredNodes</c> + <c>AttributeValueUpdate</c> are actually handled, not
///       intercepted);</item>
///       <item>a real <see cref="AddressSpaceApplier"/> over a recording
///       <see cref="IOpcUaAddressSpaceSink"/> (so the materialise + value-write reach the sink).</item>
///     </list>
///
///     <para>
///         The assertions are therefore made on the SINK's recorded <c>EnsureVariable</c> /
///         <c>RaiseNodesAddedModelChange</c> / <c>WriteValue</c> calls — i.e. the discovered node was
///         materialised through the real applier AND a published value surfaces <see cref="OpcUaQuality.Good"/>
///         at the mapped NodeId (in production this overwrites the <c>BadWaitingForInitialData</c> seed that
///         <c>OtOpcUaNodeManager.EnsureVariable</c> stamps on a freshly-materialised variable; the recording
///         sink does not model that seed, so the faithful assertion available here is that the live value
///         lands Good at the same NodeId the materialise created).
///     </para>
///
///     <para>
///         <b>Seam choices (faithful to the sibling suites).</b> Discovery is driven by Telling the host
///         <see cref="DriverInstanceActor.DiscoveredNodesReady"/> directly, and the polled value by Telling
///         <see cref="DriverInstanceActor.AttributeValuePublished"/> directly — exactly the seams the Task-7/8
///         tests use (there is no test seam to drive a real <see cref="ITagDiscovery"/> poll loop through a
///         child to Connected, and the spawned child is a <see cref="SubscribableStubDriver"/>). The publish
///         actor is wired WITHOUT a dbFactory, so the host's apply-time <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
///         falls back to a raw <c>sink.RebuildAddressSpace()</c> (no <c>EnsureVariable</c>); this keeps the
///         ONLY <c>EnsureVariable</c> traffic on the sink the discovered-node materialise itself, so the
///         mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing
///         map) is fully real.
///     </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
    private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
    private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);

    // The FixedTree node the driver "discovers": FOCAS/<deviceHost>/Identity/SeriesNumber, a String value,
    // whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored
    // node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber.
    private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber";
    private const string FixedTreeDisplayName = "SeriesNumber";

    // The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) +
    // the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host
    // segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse
    // "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this
    // EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the
    // collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber").
    private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber";

    private static DiscoveredNode[] FixedTreeNodes() => new[]
    {
        new DiscoveredNode(
            FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
            BrowseName: "SeriesNumber",
            DisplayName: FixedTreeDisplayName,
            FullReference: FixedTreeRef,
            DataType: DriverDataType.String,
            IsArray: false,
            ArrayDim: null,
            Writable: false,
            IsHistorized: false),
    };

    /// <summary>
    ///     End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows
    ///     Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a
    ///     real publish actor + real applier + recording sink, then asserts:
    ///     (a) the sink recorded an <c>EnsureVariable</c> for the FixedTree node under EQ-1 (materialised
    ///         through the REAL applier — the node now exists in the served address space), with a
    ///         <c>RaiseNodesAddedModelChange</c> under EQ-1 so connected clients refresh;
    ///     (b) after an <see cref="DriverInstanceActor.AttributeValuePublished"/> for the FixedTree ref, the
    ///         sink recorded a <c>WriteValue</c> at THAT SAME NodeId carrying the value with
    ///         <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
    ///         overwrote the BadWaitingForInitialData seed.
    /// </summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId);

        // Driver reports its captured FixedTree (the faithful Task-7/8 seam).
        host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));

        // (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT
        // collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse).
        AwaitAssert(() =>
        {
            var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
            v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1
            v.DataType.ShouldBe("String");              // mapper carried the driver type through to the sink
            v.Writable.ShouldBeFalse();                 // discovered nodes are read-only
            sink.ModelChanges.ShouldContain("EQ-1");    // NodeAdded announced under the equipment
        }, duration: Timeout);

        // (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live
        // value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue).
        host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts));

        AwaitAssert(() =>
        {
            var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId);
            write.NodeId.ShouldBe(ExpectedFixedTreeNodeId);
            write.Value.ShouldBe("SN-12345");
            write.Quality.ShouldBe(OpcUaQuality.Good);
            write.Ts.ShouldBe(Ts);
        }, duration: Timeout);
    }

    /// <summary>
    ///     End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a
    ///     redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new
    ///     revision, same d1 → EQ-1 binding) re-runs <c>PushDesiredSubscriptions</c> — which clears the
    ///     routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts
    ///     the cached discovered plan. Asserts the sink records the FixedTree <c>EnsureVariable</c> AGAIN
    ///     (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
    ///     <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
    /// </summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Discovered_node_and_value_survive_a_redeploy()
    {
        var db = NewInMemoryDbFactory();
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId);

        host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));

        // First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1.
        AwaitAssert(
            () => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName),
            duration: Timeout);

        // First value flows Good (pre-redeploy baseline).
        host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts));
        AwaitAssert(
            () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good),
            duration: Timeout);

        var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId);

        // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions
        // (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild.
        AwaitAssert(
            () => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
            duration: Timeout);

        // (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the
        // live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it).
        var tsAfter = Ts.AddSeconds(5);
        host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter));
        AwaitAssert(
            () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
            duration: Timeout);
    }

    /// <summary>Spawns the real chain — recording sink → real <see cref="AddressSpaceApplier"/> → real
    /// <see cref="OpcUaPublishActor"/> (the host's <c>opcUaPublishActor</c> seam) → real
    /// <see cref="DriverHostActor"/> backed by a <see cref="SubscribableStubDriver"/> — dispatches the
    /// deployment, and waits for the Applied ACK so <c>_lastComposition</c> + the live child + the initial
    /// subscribe pass have completed before discovery is injected. The publish actor is wired with the
    /// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable)
    /// and the only EnsureVariable traffic is the discovered-node materialise itself.</summary>
    private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor(
        IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
    {
        var coordinator = CreateTestProbe();
        var vtHost = CreateTestProbe();

        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
        var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));

        var host = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: new SubscribingDriverFactory("Modbus"),
            localRoles: new HashSet<string> { "driver" },
            opcUaPublishActor: publish,
            virtualTagHostOverride: vtHost.Ref));

        host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        return (host, sink, coordinator);
    }

    /// <summary>Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project
    /// equipment tags + a real (non-stubbed) <see cref="DriverInstanceActor"/> child for each driver
    /// (mirrors <c>DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags</c>). An authored value tag
    /// both sets <c>_lastComposition</c> and binds the driver → equipment (the only way the host resolves the
    /// equipment a discovered node grafts under).</summary>
    private static DeploymentId SeedDeploymentWithEquipmentTags(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
        params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
    {
        var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();

        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Namespaces = new[]
            {
                new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
            },
            DriverInstances = driverIds.Select(d => new
            {
                DriverInstanceRowId = Guid.NewGuid(),
                DriverInstanceId = d,
                Name = d,
                DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
                Enabled = true,
                DriverConfig = "{}",
                NamespaceId = "ns-eq",
            }).ToArray(),
            Tags = tags.Select((t, i) => new
            {
                TagId = $"tag-{i}",
                EquipmentId = t.Equip,
                DriverInstanceId = t.Driver,
                Name = t.Name,
                FolderPath = t.Folder,
                DataType = "Double",
                TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
            }).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>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
    /// type, so a real (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the driver and
    /// the host's subscribe path is exercised (mirrors
    /// <c>DriverHostActorDiscoveryTests.SubscribingDriverFactory</c>).</summary>
    private sealed class SubscribingDriverFactory : IDriverFactory
    {
        private readonly string _supportedType;
        private readonly SubscribableStubDriver _driver = new();
        public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }

        /// <inheritdoc />
        public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
            string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;

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

    /// <summary>Recording <see cref="IOpcUaAddressSpaceSink"/> — captures the EnsureFolder / EnsureVariable /
    /// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can
    /// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the
    /// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread).</summary>
    private sealed class RecordingSink : IOpcUaAddressSpaceSink
    {
        private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new();
        private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new();
        private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new();
        private readonly ConcurrentQueue<string> _modelChanges = new();

        /// <summary>Gets a snapshot of the recorded EnsureFolder calls.</summary>
        public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList();
        /// <summary>Gets a snapshot of the recorded EnsureVariable calls.</summary>
        public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList();
        /// <summary>Gets a snapshot of the recorded WriteValue calls.</summary>
        public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList();
        /// <summary>Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements.</summary>
        public List<string> ModelChanges => _modelChanges.ToList();
        /// <summary>Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback).</summary>
        public int RebuildCalls;

        /// <summary>Records a live-value write.</summary>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
            => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));

        /// <summary>No-op: alarm writes are not exercised by this suite.</summary>
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }

        /// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }

        /// <summary>Records an EnsureFolder call.</summary>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
            => _folders.Enqueue((folderNodeId, parentNodeId, displayName));

        /// <summary>Records an EnsureVariable call.</summary>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
            => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));

        /// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
        public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);

        /// <summary>Records a NodeAdded model-change announcement.</summary>
        public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
    }
}
