using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;

/// <summary>
/// Behaviour of <see cref="CentralControlGrpcService"/> that does not need a real gRPC pipeline:
/// the readiness gate, the fire-and-forget heartbeat contract, the ingest-timeout status
/// mapping, and the shared audit-ingest timeout constant. The auth interceptor + real transport
/// are proven separately in <c>CentralControlEndToEndTests</c> (Host.Tests).
/// </summary>
public class CentralControlGrpcServiceTests : TestKit
{
    private static ServerCallContext NewContext(CancellationToken ct = default)
    {
        var context = Substitute.For<ServerCallContext>();
        context.CancellationToken.Returns(ct);
        return context;
    }

    private CentralControlGrpcService CreateService(CommunicationOptions? options = null)
        => new(
            NullLogger<CentralControlGrpcService>.Instance,
            Options.Create(options ?? new CommunicationOptions()));

    [Fact]
    public async Task BeforeSetReady_AUnaryCall_IsUnavailable()
    {
        // Nothing was dispatched, so Unavailable is the right status — it tells a site transport
        // the call never ran and a cross-node retry is safe.
        var service = CreateService();

        var ex = await Assert.ThrowsAsync<RpcException>(
            () => service.SubmitNotification(new NotificationSubmitDto(), NewContext()));

        Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
    }

    [Fact]
    public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable()
    {
        var service = CreateService();
        var batch = new AuditEventBatch();
        batch.Events.Add(NewAuditDto());

        var ex = await Assert.ThrowsAsync<RpcException>(
            () => service.IngestAuditEvents(batch, NewContext()));

        Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
    }

    [Fact]
    public async Task AfterSetReady_AUnaryCall_ReachesTheActor()
    {
        var stub = Sys.ActorOf(Props.Create(() => new StubActor()));
        var service = CreateService();
        service.SetReady(stub);

        var ack = await service.SubmitNotification(NewNotificationDto(), NewContext());

        Assert.True(ack.Accepted);
    }

    [Fact]
    public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped()
    {
        // Fire-and-forget end-to-end: a heartbeat must never fault the site's timer, so even
        // with no actor wired the call returns OK rather than Unavailable.
        var service = CreateService();

        var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext());

        Assert.NotNull(reply);
    }

    [Fact]
    public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks()
    {
        // A black-hole actor that never replies would hang an Ask forever; the heartbeat still
        // returns immediately, proving it is a Tell, not an Ask.
        var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor()));
        var service = CreateService();
        service.SetReady(blackHole);

        var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext());

        Assert.NotNull(reply);
    }

    [Fact]
    public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable()
    {
        // The message WAS delivered (Unavailable would wrongly invite a duplicate retry on the
        // peer node); a timeout is DeadlineExceeded, which callers never cross-node-retry.
        var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor()));
        var service = CreateService(new CommunicationOptions
        {
            NotificationForwardTimeout = TimeSpan.FromMilliseconds(200),
        });
        service.SetReady(blackHole);

        var ex = await Assert.ThrowsAsync<RpcException>(
            () => service.SubmitNotification(NewNotificationDto(), NewContext()));

        Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode);
    }

    [Fact]
    public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady()
    {
        // An empty batch is a no-op the actor need never see; it must not depend on readiness.
        var service = CreateService();

        var ack = await service.IngestAuditEvents(new AuditEventBatch(), NewContext());

        Assert.Empty(ack.AcceptedEventIds);
    }

    [Fact]
    public void TheAuditIngestTimeout_IsTheOneSharedConstant()
    {
        // The plan calls SiteStreamGrpcServer.AuditIngestAskTimeout "one source of truth" shared
        // between the two audit-ingest transports; the central service must not re-declare 30s.
        Assert.Equal(TimeSpan.FromSeconds(30), SiteStreamGrpcServer.AuditIngestAskTimeout);
    }

    // The mapper reads SiteEnqueuedAt unconditionally, so a DTO that reaches mapping must carry
    // a timestamp. Only DTOs that get past the readiness/auth gate map, so the negative tests
    // above can pass a bare DTO.
    private static NotificationSubmitDto NewNotificationDto() => new()
    {
        NotificationId = Guid.NewGuid().ToString(),
        ListName = "ops",
        SiteEnqueuedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
            DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
    };

    private static HeartbeatDto NewHeartbeatDto() => new()
    {
        SiteId = "site-a",
        NodeHostname = "node-a",
        IsActive = true,
        Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
            DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
    };

    private static AuditEventDto NewAuditDto() => new()
    {
        EventId = Guid.NewGuid().ToString(),
        OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
            DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
        Channel = "ApiOutbound",
        Kind = "ApiCall",
        Status = "Delivered",
        SourceSiteId = "site-a",
    };

    private sealed class StubActor : ReceiveActor
    {
        public StubActor()
        {
            Receive<NotificationSubmit>(msg =>
                Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));
        }
    }

    /// <summary>Swallows every message and never replies, so an Ask against it times out.</summary>
    private sealed class NeverRepliesActor : ReceiveActor
    {
        public NeverRepliesActor() => ReceiveAny(_ => { });
    }
}
