using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

/// <summary>
///     Per-cluster mesh Phase 3 (Task 5) — the <c>FetchAndCache</c> apply path: on dispatch the node
///     fetches the artifact bytes over gRPC, applies them from bytes in hand, and caches them —
///     reading NO config from central SQL. A null fetch is an apply failure that keeps last-known-good
///     and does not advance the revision (#485).
/// </summary>
public sealed class DriverHostActorFetchAndCacheTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("driver-test");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));

    /// <summary>Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in.</summary>
    private static byte[] ArtifactBytes() =>
        JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty<object>() });

    [Fact]
    public void FetchReturningGoodBytes_AppliesAndCaches_WithoutReadingCentralSql()
    {
        // No Deployment row is seeded: if the apply path read ArtifactBlob from SQL it would get empty
        // bytes and FAIL (#485). Reaching Applied proves the bytes came from the fetcher, not SQL.
        var db = NewInMemoryDbFactory();
        var cache = new RecordingArtifactCache();
        var fetcher = new RecordingFetcher(ArtifactBytes());
        var deploymentId = DeploymentId.NewId();

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            localRoles: new HashSet<string> { "driver" },
            deploymentArtifactCache: cache,
            fetchAndCacheMode: true,
            artifactFetcher: fetcher));

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

        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
        cache.Stores[0].RevisionHash.ShouldBe(RevA.Value);
        fetcher.Calls.ShouldBe(1);
    }

    [Fact]
    public void FetchReturningNull_FailsTheApply_KeepsRevisionUnchanged_AndCachesNothing()
    {
        var db = NewInMemoryDbFactory();
        var cache = new RecordingArtifactCache();
        var fetcher = new RecordingFetcher(bytes: null); // all endpoints down / NotFound / SHA mismatch
        var deploymentId = DeploymentId.NewId();

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            localRoles: new HashSet<string> { "driver" },
            deploymentArtifactCache: cache,
            fetchAndCacheMode: true,
            artifactFetcher: fetcher));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed);

        Thread.Sleep(300);
        cache.Stores.ShouldBeEmpty();

        // The revision was NOT advanced: a re-dispatch of the SAME revision fetches AGAIN rather than
        // short-circuiting as "already current". (Two fetch attempts prove #485's keep-last-known-good.)
        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed);
        fetcher.Calls.ShouldBe(2);
    }

    [Fact]
    public void RedispatchOfTheAppliedRevision_DoesNotFetchAgain()
    {
        var db = NewInMemoryDbFactory();
        var cache = new RecordingArtifactCache();
        var fetcher = new RecordingFetcher(ArtifactBytes());
        var deploymentId = DeploymentId.NewId();

        var coordinator = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            localRoles: new HashSet<string> { "driver" },
            deploymentArtifactCache: cache,
            fetchAndCacheMode: true,
            artifactFetcher: fetcher));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // Re-dispatch the now-current revision: the _currentRevision guard short-circuits to an
        // immediate ACK without a second fetch.
        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        Thread.Sleep(200);
        fetcher.Calls.ShouldBe(1);
    }

    /// <summary>Records fetch calls and returns canned bytes (or null for "no answer").</summary>
    private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher
    {
        private int _calls;
        public int Calls => Volatile.Read(ref _calls);

        public Task<byte[]?> FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct)
        {
            Interlocked.Increment(ref _calls);
            return Task.FromResult(bytes);
        }
    }

    /// <summary>Records every store; GetCurrent* return null (no idempotency shortcut in these tests).</summary>
    private sealed class RecordingArtifactCache : IDeploymentArtifactCache
    {
        private readonly Lock _gate = new();
        private readonly List<StoreCall> _stores = [];

        public IReadOnlyList<StoreCall> Stores
        {
            get { lock (_gate) { return _stores.ToArray(); } }
        }

        public Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
                               byte[] artifact, CancellationToken ct = default)
        {
            lock (_gate) { _stores.Add(new StoreCall(clusterId, deploymentId, revisionHash, artifact)); }
            return Task.CompletedTask;
        }

        public Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default)
            => Task.FromResult<CachedDeploymentArtifact?>(null);

        public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
            => Task.FromResult<CachedDeploymentArtifact?>(null);

        internal sealed record StoreCall(
            string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact);
    }
}
