using System.Buffers.Binary;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;

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

/// <summary>
/// Arch-review phase-2 residual #3: <see cref="CentralChannelProvider"/>'s failback probe reuses
/// the <c>Heartbeat</c> RPC to ask "does the preferred central endpoint answer again?". It is
/// emitted by a site's TRANSPORT layer, not by any node's heartbeat timer, so central must not
/// count it as liveness — otherwise a site whose real heartbeats had stopped keeps looking alive
/// on the health dashboard for as long as its transport keeps probing.
/// <para>
/// The contract is the explicit additive <c>Synthetic</c> flag (<c>HeartbeatDto.synthetic</c>,
/// proto field 5), NOT the <c>failback-probe</c> hostname, which is a log label only.
/// </para>
/// </summary>
public class SyntheticHeartbeatTests : TestKit
{
    private static readonly DateTimeOffset T0 =
        new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero);

    // ── The consumer: central skips liveness bookkeeping for a synthetic heartbeat ───

    [Fact]
    public void SyntheticHeartbeat_DoesNotMarkTheHealthAggregator()
    {
        var (actor, aggregator) = CreateCentralActor();

        actor.Tell(new HeartbeatMessage("site-1", CentralChannelProvider.SyntheticProbeHostname,
            IsActive: false, Timestamp: T0, Synthetic: true));

        // Give the actor a real chance to (wrongly) mark before asserting the negative.
        ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        aggregator.DidNotReceiveWithAnyArgs().MarkHeartbeat(default!, default);
    }

    [Fact]
    public void RealHeartbeat_StillMarksTheHealthAggregator()
    {
        // The guard must be keyed on the flag alone — an ordinary heartbeat is unaffected,
        // including one from a node that predates the field (proto3 defaults it to false).
        var (actor, aggregator) = CreateCentralActor();

        actor.Tell(new HeartbeatMessage("site-1", "node-a", IsActive: true, Timestamp: T0));

        AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", T0));
    }

    [Fact]
    public void SyntheticHeartbeatReplica_IsAlsoSkipped()
    {
        // Belt-and-braces on the last hop before the aggregator: a peer central node that
        // predates the flag could still replicate one.
        var (actor, aggregator) = CreateCentralActor();

        actor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage(
            "site-1", CentralChannelProvider.SyntheticProbeHostname,
            IsActive: false, Timestamp: T0, Synthetic: true)));

        ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        aggregator.DidNotReceiveWithAnyArgs().MarkHeartbeat(default!, default);
    }

    // ── The wire contract: the flag survives the round trip ─────────────────────────

    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void TheSyntheticFlag_RoundTripsThroughTheDto(bool synthetic)
    {
        var msg = new HeartbeatMessage("site-1", "node-a", IsActive: true, Timestamp: T0,
            Synthetic: synthetic);

        var back = CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(msg));

        Assert.Equal(synthetic, back.Synthetic);
        Assert.Equal(msg with { Synthetic = synthetic }, back);
    }

    [Fact]
    public void ADtoFromAnOlderSite_DefaultsToNotSynthetic()
    {
        // proto3 default: a peer that never sets field 5 sends a REAL heartbeat, as before.
        var dto = new HeartbeatDto
        {
            SiteId = "site-1",
            NodeHostname = "node-a",
            IsActive = true,
            Timestamp = Timestamp.FromDateTimeOffset(T0),
        };

        Assert.False(CentralControlDtoMapper.FromDto(dto).Synthetic);
    }

    // ── The producer: the failback probe marks itself synthetic ─────────────────────

    [Fact]
    public async Task TheFailbackProbe_MarksItsHeartbeatSynthetic()
    {
        // Two endpoints so a flip is possible; the capture handler answers nothing useful, so
        // the probe faults and re-arms — we only care about the request it put on the wire.
        var capture = new HeartbeatCapturingHandler();
        using var provider = new CentralChannelProvider(
            new[] { "http://central-a:8083", "http://central-b:8083" },
            new FixedPskProvider("k"),
            "site-1",
            new CommunicationOptions(),
            NullLogger.Instance,
            handlerFactory: _ => capture,
            probeDeadline: TimeSpan.FromSeconds(2),
            backoffBase: TimeSpan.FromMilliseconds(20),
            backoffCap: TimeSpan.FromMilliseconds(50));

        // Off the preferred endpoint → the background failback probe arms.
        provider.ReportUnavailable(0);

        var probe = await capture.WaitForHeartbeatAsync(TimeSpan.FromSeconds(10));

        Assert.True(probe.Synthetic);
        Assert.Equal("site-1", probe.SiteId);
        // The hostname is a human-readable label that rides ALONGSIDE the flag; central keys
        // its skip on the flag, never on this string.
        Assert.Equal(CentralChannelProvider.SyntheticProbeHostname, probe.NodeHostname);
        Assert.False(probe.IsActive);
    }

    private (IActorRef Actor, ICentralHealthAggregator Aggregator) CreateCentralActor()
    {
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>());

        var aggregator = Substitute.For<ICentralHealthAggregator>();

        var services = new ServiceCollection();
        services.AddScoped(_ => siteRepo);
        services.AddSingleton(aggregator);
        var sp = services.BuildServiceProvider();

        var actor = Sys.ActorOf(Props.Create(() =>
            new CentralCommunicationActor(sp, Substitute.For<ISiteCommandTransport>())));
        return (actor, aggregator);
    }

    private sealed class FixedPskProvider(string key) : ISitePskProvider
    {
        public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
        public void Invalidate(string siteId) { }
    }

    /// <summary>
    /// Captures the first <c>Heartbeat</c> request body and decodes the length-prefixed gRPC
    /// frame back into a <see cref="HeartbeatDto"/>. The response is deliberately a bare 500 so
    /// the probe treats the endpoint as still down; the provider swallows that and re-arms.
    /// </summary>
    private sealed class HeartbeatCapturingHandler : HttpMessageHandler
    {
        private readonly TaskCompletionSource<HeartbeatDto> _captured =
            new(TaskCreationOptions.RunContinuationsAsynchronously);

        public async Task<HeartbeatDto> WaitForHeartbeatAsync(TimeSpan timeout)
        {
            var completed = await Task.WhenAny(_captured.Task, Task.Delay(timeout));
            Assert.Same(_captured.Task, completed);
            return await _captured.Task;
        }

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            if (request.Content is not null &&
                request.RequestUri?.AbsolutePath.EndsWith("/Heartbeat", StringComparison.Ordinal) == true)
            {
                var body = await request.Content.ReadAsByteArrayAsync(cancellationToken);
                // gRPC frame: 1 compression byte + 4-byte big-endian length + payload.
                if (body.Length >= 5)
                {
                    var length = BinaryPrimitives.ReadInt32BigEndian(body.AsSpan(1, 4));
                    _captured.TrySetResult(
                        HeartbeatDto.Parser.ParseFrom(body.AsSpan(5, length).ToArray()));
                }
            }

            return new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)
            {
                Version = request.Version,
            };
        }
    }
}
