using System.Security.Cryptography;
using Google.Protobuf;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;

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

/// <summary>
///     Per-cluster mesh Phase 3 (Task 4) — the node-side gRPC artifact fetcher's logic: reassembly,
///     SHA-256 verification, endpoint failover, and the null-on-any-failure (#485) contract. The real
///     gRPC stream across a real Kestrel h2c listener is the Task 8 boundary test; here the client is
///     a fake streaming canned chunks, so only the fetcher's own logic is under test.
/// </summary>
public sealed class GrpcDeploymentArtifactFetcherTests
{
    private const string DeploymentId = "11111111-1111-1111-1111-111111111111";

    private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));

    private static GrpcDeploymentArtifactFetcher Fetcher(
        string[] endpoints, Func<string, DeploymentArtifactService.DeploymentArtifactServiceClient> factory)
        => new(
            Options.Create(new ConfigSourceOptions
            {
                Mode = ConfigSourceOptions.ModeFetchAndCache,
                CentralFetchEndpoints = endpoints,
                ApiKey = "k",
                FetchTimeoutSeconds = 30,
            }),
            NullLogger<GrpcDeploymentArtifactFetcher>.Instance,
            factory);

    [Fact]
    public async Task Chunks_reassembling_to_the_expected_hash_return_the_exact_bytes()
    {
        var blob = new byte[300 * 1024];
        for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251);
        var factory = SingleClient(StreamOf(blob, chunkSize: 128 * 1024));

        var result = await Fetcher(["http://central-1:4055"], factory)
            .FetchAsync(DeploymentId, Sha256Hex(blob), CancellationToken.None);

        result.ShouldBe(blob);
    }

    [Fact]
    public async Task A_hash_mismatch_returns_null()
    {
        var blob = new byte[] { 1, 2, 3, 4 };
        var factory = SingleClient(StreamOf(blob, chunkSize: 2));

        var result = await Fetcher(["http://central-1:4055"], factory)
            .FetchAsync(DeploymentId, Sha256Hex([9, 9, 9, 9]), CancellationToken.None);

        result.ShouldBeNull();
    }

    [Fact]
    public async Task An_empty_stream_returns_null()
    {
        var factory = SingleClient(StreamOf([], chunkSize: 128 * 1024));

        var result = await Fetcher(["http://central-1:4055"], factory)
            .FetchAsync(DeploymentId, Sha256Hex([]), CancellationToken.None);

        result.ShouldBeNull();
    }

    [Fact]
    public async Task When_the_first_endpoint_is_unavailable_it_fails_over_to_the_second()
    {
        var blob = new byte[] { 10, 20, 30, 40, 50 };
        var consulted = new List<string>();
        var factory = (string endpoint) =>
        {
            consulted.Add(endpoint);
            return endpoint.Contains("central-1")
                ? (DeploymentArtifactService.DeploymentArtifactServiceClient)
                    new ThrowingClient(new RpcException(new Status(StatusCode.Unavailable, "down")))
                : new FakeClient(StreamOf(blob, chunkSize: 2));
        };

        var result = await Fetcher(["http://central-1:4055", "http://central-2:4055"], factory)
            .FetchAsync(DeploymentId, Sha256Hex(blob), CancellationToken.None);

        result.ShouldBe(blob);
        // The failover must have actually consulted the second endpoint.
        consulted.ShouldBe(["http://central-1:4055", "http://central-2:4055"]);
    }

    [Fact]
    public async Task When_every_endpoint_throws_it_returns_null()
    {
        var factory = (string _) =>
            (DeploymentArtifactService.DeploymentArtifactServiceClient)
                new ThrowingClient(new RpcException(new Status(StatusCode.NotFound, "unknown deployment")));

        var result = await Fetcher(["http://central-1:4055", "http://central-2:4055"], factory)
            .FetchAsync(DeploymentId, Sha256Hex([1]), CancellationToken.None);

        result.ShouldBeNull();
    }

    // ---- fakes --------------------------------------------------------------------------------

    private static Func<string, DeploymentArtifactService.DeploymentArtifactServiceClient> SingleClient(
        IReadOnlyList<ArtifactChunk> chunks)
        => _ => new FakeClient(chunks);

    private static IReadOnlyList<ArtifactChunk> StreamOf(byte[] blob, int chunkSize)
    {
        var chunks = new List<ArtifactChunk>();
        for (var offset = 0; offset < blob.Length; offset += chunkSize)
        {
            var len = Math.Min(chunkSize, blob.Length - offset);
            chunks.Add(new ArtifactChunk { Data = ByteString.CopyFrom(blob, offset, len) });
        }

        return chunks;
    }

    /// <summary>A generated client double whose <c>Fetch</c> streams canned chunks.</summary>
    private sealed class FakeClient(IReadOnlyList<ArtifactChunk> chunks)
        : DeploymentArtifactService.DeploymentArtifactServiceClient
    {
        public override AsyncServerStreamingCall<ArtifactChunk> Fetch(FetchRequest request, CallOptions options)
            => new(
                new ListStreamReader(chunks),
                Task.FromResult(new Metadata()),
                () => Status.DefaultSuccess,
                () => [],
                () => { });
    }

    /// <summary>A generated client double whose <c>Fetch</c> throws (endpoint down / NotFound).</summary>
    private sealed class ThrowingClient(RpcException toThrow)
        : DeploymentArtifactService.DeploymentArtifactServiceClient
    {
        public override AsyncServerStreamingCall<ArtifactChunk> Fetch(FetchRequest request, CallOptions options)
            => throw toThrow;
    }

    private sealed class ListStreamReader(IReadOnlyList<ArtifactChunk> chunks) : IAsyncStreamReader<ArtifactChunk>
    {
        private int _index = -1;

        public ArtifactChunk Current => chunks[_index];

        public Task<bool> MoveNext(CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            _index++;
            return Task.FromResult(_index < chunks.Count);
        }
    }
}
