using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
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.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

/// <summary>
///     Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a
///     <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves
///     the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in
///     <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same
///     <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a
///     <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the
///     right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker.
///
///     <para>
///         Drives a real apply through the existing harness (same artifact shape as
///         <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real
///         (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
///         recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
///         the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
///         driver child, so this end-to-end approach (recording driver) is the closest faithful test the
///         harness allows.
///     </para>
/// </summary>
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("driver-wr-test");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);

    /// <summary>On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to
    /// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
    {
        var db = NewInMemoryDbFactory();
        var recorder = new RecordingDriverFactory("Modbus");
        // One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));

        var actor = SpawnHostAndApply(db, deploymentId, recorder);

        // Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
        var asker = CreateTestProbe();
        actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);

        var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
        result.Success.ShouldBeTrue();

        // The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
        AwaitAssert(() =>
        {
            recorder.Writes.Count.ShouldBe(1);
            recorder.Writes[0].FullReference.ShouldBe("40001");
            recorder.Writes[0].Value.ShouldBe(123.0);
        }, duration: Timeout);
    }

    /// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
    /// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary>
    [Fact]
    public void Secondary_rejects_write_and_does_not_forward_to_driver()
    {
        var db = NewInMemoryDbFactory();
        var recorder = new RecordingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));

        var actor = SpawnHostAndApply(db, deploymentId, recorder);

        // Force this node Secondary so the primary gate rejects.
        actor.Tell(new RedundancyStateChanged(
            new[]
            {
                new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
                    IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow),
            },
            CorrelationId.NewId()));

        var asker = CreateTestProbe();
        actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);

        var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
        result.Success.ShouldBeFalse();
        result.Reason.ShouldBe("not primary");

        // No write reached the driver — the gate short-circuited before the reverse-map lookup.
        recorder.Writes.ShouldBeEmpty();
    }

    /// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary>
    [Fact]
    public void Unknown_node_id_replies_failure()
    {
        var db = NewInMemoryDbFactory();
        var recorder = new RecordingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));

        var actor = SpawnHostAndApply(db, deploymentId, recorder);

        var asker = CreateTestProbe();
        actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);

        var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        recorder.Writes.ShouldBeEmpty();
    }

    /// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and
    /// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw
    /// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
    /// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> and the driver receives that exact string as its
    /// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Primary_routes_write_for_raw_protocol_blob_tag()
    {
        var db = NewInMemoryDbFactory();
        var recorder = new RecordingDriverFactory("Modbus");
        // Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
        // ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
        // wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
        var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
            equip: "eq-2", driver: "drv-2", name: "torque");

        var actor = SpawnHostAndApply(db, deploymentId, recorder);

        // Local role unknown ⇒ treated as Primary ⇒ write allowed.
        var asker = CreateTestProbe();
        actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);

        var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
        result.Success.ShouldBeTrue();

        // The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
        // there is no top-level "FullName" key in the blob.
        AwaitAssert(() =>
        {
            recorder.Writes.Count.ShouldBe(1);
            recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
            recorder.Writes[0].Value.ShouldBe(456.0);
        }, duration: Timeout);
    }

    /// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
    /// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
    /// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
    /// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
    [Fact]
    public void Stale_host_fast_fails_route_node_write()
    {
        // A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
        var db = new ThrowingDbFactory();
        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            localRoles: new HashSet<string> { "driver" }));

        var asker = CreateTestProbe();
        actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);

        var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason!.ShouldContain("stale");
    }

    /// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits
    /// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
    /// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
    /// only the write path, which doesn't depend on the publish actor.</summary>
    private IActorRef SpawnHostAndApply(
        IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
    {
        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>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        return actor;
    }

    /// <summary>
    ///     Seeds a Sealed deployment whose artifact carries the minimal arrays
    ///     <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors
    ///     <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a
    ///     <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag
    ///     so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
    /// </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>
    ///     Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver
    ///     blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
    ///     <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
    ///     <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
    ///     returning the raw blob string verbatim — that raw string becomes the
    ///     <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> reverse-map key, and the driver receives it as
    ///     <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
    ///     raw blob string so the caller can assert the forwarded wire-ref precisely.
    /// </summary>
    private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
        string equip, string driver, string name)
    {
        // Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
        var rawBlobString = JsonSerializer.Serialize(
            new { region = "HoldingRegister", address = 200, dataType = "UInt16" });

        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Namespaces = new[]
            {
                new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
            },
            DriverInstances = new[]
            {
                new
                {
                    DriverInstanceRowId = Guid.NewGuid(),
                    DriverInstanceId = driver,
                    Name = driver,
                    DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
                    Enabled = true,
                    DriverConfig = "{}",
                    NamespaceId = "ns-eq",
                },
            },
            Tags = new[]
            {
                new
                {
                    TagId = "tag-raw",
                    EquipmentId = equip,
                    DriverInstanceId = driver,
                    Name = name,
                    FolderPath = (string?)null,
                    DataType = "Double",
                    // Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
                    TagConfig = rawBlobString,
                },
            },
        });

        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, rawBlobString);
    }

    /// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
    /// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
    /// so a write can be routed at a Stale host.</summary>
    private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
    {
        /// <inheritdoc />
        public OtOpcUaConfigDbContext CreateDbContext() =>
            throw new InvalidOperationException("config DB unreachable (test stub)");
    }

    /// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type, whose
    /// recorded write list is exposed for assertions.</summary>
    private sealed class RecordingDriverFactory : IDriverFactory
    {
        private readonly string _supportedType;
        private readonly RecordingDriver _driver = new();
        public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }

        /// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary>
        public IReadOnlyList<WriteRequest> Writes => _driver.Writes;

        /// <inheritdoc />
        public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
        {
            if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
            _driver.Bind(driverInstanceId, driverType);
            return _driver;
        }

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

    /// <summary>An <see cref="IDriver"/> + <see cref="IWritable"/> that records every write and returns Good.</summary>
    private sealed class RecordingDriver : IDriver, IWritable
    {
        private readonly ConcurrentQueue<WriteRequest> _writes = new();
        /// <inheritdoc />
        public string DriverInstanceId { get; private set; } = string.Empty;
        /// <inheritdoc />
        public string DriverType { get; private set; } = string.Empty;
        /// <summary>The writes received so far.</summary>
        public IReadOnlyList<WriteRequest> Writes => _writes.ToArray();
        /// <summary>Sets the identity once the factory is asked to create it.</summary>
        public void Bind(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;
        /// <inheritdoc />
        public Task<IReadOnlyList<WriteResult>> WriteAsync(
            IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
        {
            foreach (var w in writes) _writes.Enqueue(w);
            return Task.FromResult<IReadOnlyList<WriteResult>>(
                writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
        }
    }
}
