using Google.Protobuf;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc;

/// <summary>
///     The serve side of Phase 3: streams a sealed deployment's bytes, and collapses unknown /
///     not-sealed / empty into one indistinguishable NotFound (existence-hiding + #485).
/// </summary>
public class DeploymentArtifactServiceTests
{
    private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);

    private static DeploymentArtifactGrpcService Service(string dbName) =>
        new(Factory(dbName), NullLogger<DeploymentArtifactGrpcService>.Instance);

    private static Guid Seed(string dbName, DeploymentStatus status, byte[] blob)
    {
        var id = Guid.NewGuid();
        using var db = new OtOpcUaConfigDbContext(
            new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
        db.Deployments.Add(new Deployment
        {
            DeploymentId = id,
            RevisionHash = new string('a', 64),
            Status = status,
            CreatedBy = "test",
            ArtifactBlob = blob,
        });
        db.SaveChanges();
        return id;
    }

    [Fact]
    public async Task A_sealed_non_empty_blob_streams_back_byte_equal_and_chunk_bounded()
    {
        var dbName = $"artsvc-{Guid.NewGuid():N}";
        // ~300 KB > 2 * 128 KiB -> exactly 3 chunks.
        var blob = new byte[300 * 1024];
        for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251);
        var id = Seed(dbName, DeploymentStatus.Sealed, blob);

        var writer = new CollectingStreamWriter();
        await Service(dbName).Fetch(
            new FetchRequest { DeploymentId = id.ToString() }, writer, new FakeServerCallContext());

        writer.Chunks.Count.ShouldBe(3);
        writer.Chunks[0].Data.Length.ShouldBe(128 * 1024);
        writer.Chunks[1].Data.Length.ShouldBe(128 * 1024);
        writer.Reassembled().ShouldBe(blob);
    }

    [Fact]
    public async Task An_unknown_id_throws_NotFound()
    {
        var dbName = $"artsvc-{Guid.NewGuid():N}";
        Seed(dbName, DeploymentStatus.Sealed, [1, 2, 3]);

        var ex = await Should.ThrowAsync<RpcException>(() => Service(dbName).Fetch(
            new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
            new CollectingStreamWriter(), new FakeServerCallContext()));

        ex.StatusCode.ShouldBe(StatusCode.NotFound);
    }

    [Fact]
    public async Task A_non_sealed_deployment_with_a_blob_is_served()
    {
        // A node fetches DURING apply, before the deployment is sealed (seal-needs-ack →
        // ack-needs-fetch). Gating on Sealed would deadlock every FetchAndCache deploy. So a
        // non-sealed deployment that has a blob must stream — matching Direct mode reading the same
        // AwaitingApplyAcks row from SQL. (Regression for the Phase 3 live-gate deadlock.)
        var dbName = $"artsvc-{Guid.NewGuid():N}";
        var blob = new byte[] { 1, 2, 3 };
        var id = Seed(dbName, DeploymentStatus.AwaitingApplyAcks, blob);

        var writer = new CollectingStreamWriter();
        await Service(dbName).Fetch(
            new FetchRequest { DeploymentId = id.ToString() }, writer, new FakeServerCallContext());

        writer.Reassembled().ShouldBe(blob);
    }

    [Fact]
    public async Task A_zero_length_blob_throws_NotFound()
    {
        // The serve-side #485 guard: never stream empty bytes as if they were a valid empty config.
        var dbName = $"artsvc-{Guid.NewGuid():N}";
        var id = Seed(dbName, DeploymentStatus.Sealed, []);

        var ex = await Should.ThrowAsync<RpcException>(() => Service(dbName).Fetch(
            new FetchRequest { DeploymentId = id.ToString() },
            new CollectingStreamWriter(), new FakeServerCallContext()));

        ex.StatusCode.ShouldBe(StatusCode.NotFound);
    }

    [Fact]
    public async Task A_non_guid_id_throws_NotFound()
    {
        var ex = await Should.ThrowAsync<RpcException>(() => Service($"artsvc-{Guid.NewGuid():N}").Fetch(
            new FetchRequest { DeploymentId = "not-a-guid" },
            new CollectingStreamWriter(), new FakeServerCallContext()));

        ex.StatusCode.ShouldBe(StatusCode.NotFound);
    }

    private sealed class CollectingStreamWriter : IServerStreamWriter<ArtifactChunk>
    {
        public List<ArtifactChunk> Chunks { get; } = [];
        public WriteOptions? WriteOptions { get; set; }

        public Task WriteAsync(ArtifactChunk message)
        {
            Chunks.Add(message);
            return Task.CompletedTask;
        }

        public byte[] Reassembled()
        {
            using var ms = new MemoryStream();
            foreach (var c in Chunks) c.Data.WriteTo(ms);
            return ms.ToArray();
        }
    }

    private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
    {
        public OtOpcUaConfigDbContext CreateDbContext() =>
            new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);

        public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
            Task.FromResult(CreateDbContext());
    }

    /// <summary>Minimal <see cref="ServerCallContext"/> — the service reads only the cancellation token.</summary>
    private sealed class FakeServerCallContext : ServerCallContext
    {
        protected override string MethodCore => "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
        protected override string HostCore => "localhost";
        protected override string PeerCore => "ipv4:127.0.0.1:0";
        protected override DateTime DeadlineCore => DateTime.MaxValue;
        protected override Metadata RequestHeadersCore => [];
        protected override CancellationToken CancellationTokenCore => CancellationToken.None;
        protected override Metadata ResponseTrailersCore { get; } = [];
        protected override Status StatusCore { get; set; }
        protected override WriteOptions? WriteOptionsCore { get; set; }
        protected override AuthContext AuthContextCore => new(null, new Dictionary<string, List<AuthProperty>>());

        protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
            throw new NotSupportedException();

        protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
    }
}
