using Akka.Actor;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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.Host.Tests;

/// <summary>
/// End-to-end proof of the central-hosted gRPC control plane (T1A.2): the real
/// <see cref="CentralControlAuthInterceptor"/> AND the real
/// <see cref="CentralControlGrpcService"/> over a real gRPC stack, the service Asking a real
/// (stub) <c>CentralCommunicationActor</c> stand-in.
/// </summary>
/// <remarks>
/// <para>
/// The interceptor is registered <b>BY TYPE on <c>AddGrpc</c></b>, exactly as <c>Program.cs</c>
/// does, and is deliberately NOT placed in DI as a singleton — pre-registering it lets DI hand
/// the instance back and bypasses <c>Grpc.AspNetCore</c>'s own activation path, which is how the
/// two-public-constructor defect escaped a green suite once before. This harness copies the
/// shape of <see cref="ControlPlaneAuthEndToEndTests"/> for the same reason.
/// </para>
/// <para>
/// Runs in-process over <see cref="TestServer"/>: no ports, no containers. A tiny Akka actor
/// stands in for <c>CentralCommunicationActor</c> so the test never touches MSSQL or a cluster;
/// the method paths, message types and mapper are the real generated/production ones.
/// </para>
/// </remarks>
public class CentralControlEndToEndTests : IAsyncLifetime
{
    private const string SiteA = "site-a";
    private const string SiteAKey = "site-a-preshared-key";
    private const string SiteB = "site-b";
    private const string SiteBKey = "site-b-preshared-key";

    // The deterministic id the stub actor "accepts" for every ingest batch, so the ingest
    // bridge can be asserted without extracting ids out of a decoded AuditEvent.
    private static readonly Guid AcceptedId = Guid.Parse("11111111-1111-1111-1111-111111111111");

    private IHost _host = null!;
    private TestServer _server = null!;
    private ActorSystem _actorSystem = null!;
    private CentralControlGrpcService _service = null!;

    /// <inheritdoc />
    public async Task InitializeAsync()
    {
        _actorSystem = ActorSystem.Create("centralcontrol-e2e-test");
        var stub = _actorSystem.ActorOf(Props.Create(() => new StubCentralActor(AcceptedId)), "central-stub");

        _service = new CentralControlGrpcService(
            NullLogger<CentralControlGrpcService>.Instance,
            Options.Create(new CommunicationOptions()));
        _service.SetReady(stub);
        // The two ingest RPCs Ask the audit-ingest singleton DIRECTLY (no CentralCommunicationActor
        // relay any more), so the service needs its own proxy handed over. Reusing the same stub
        // keeps one actor answering both shapes.
        _service.SetAuditIngestActor(stub);

        var pskProvider = new MapPskProvider(new Dictionary<string, string>
        {
            [SiteA] = SiteAKey,
            [SiteB] = SiteBKey,
        });

        _host = await new HostBuilder()
            .ConfigureWebHost(web => web
                .UseTestServer()
                .ConfigureServices(services =>
                {
                    // BY TYPE, and NOT also in DI — see the class remarks.
                    services.AddGrpc(o => o.Interceptors.Add<CentralControlAuthInterceptor>());
                    services.AddSingleton<ISitePskProvider>(pskProvider);
                    services.AddSingleton(_service);
                })
                .Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(e => e.MapGrpcService<CentralControlGrpcService>());
                }))
            .StartAsync();

        _server = _host.GetTestServer();
    }

    /// <inheritdoc />
    public async Task DisposeAsync()
    {
        await _host.StopAsync();
        _host.Dispose();
        await _actorSystem.Terminate();
    }

    /// <summary>Builds a channel credentialed for <paramref name="siteId"/> with <paramref name="key"/>.</summary>
    private GrpcChannel Channel(string? key, string siteId)
    {
        var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() };
        if (key is not null)
        {
            options.WithSiteCredentials(new FixedPskProvider(key), siteId);
        }
        return GrpcChannel.ForAddress(_server.BaseAddress, options);
    }

    private CentralControlService.CentralControlServiceClient Client(string? key, string siteId)
        => new(Channel(key, siteId));

    // ---- Auth positives / negatives, all through the real pipeline ----

    [Fact]
    public async Task CorrectSiteAndKey_ReachesTheService_OnAUnaryCall()
    {
        var client = Client(SiteAKey, SiteA);

        var ack = await client.SubmitNotificationAsync(NewNotificationDto());

        Assert.True(ack.Accepted);
    }

    [Fact]
    public async Task NoCredentialsAtAll_IsRejected_WithPermissionDenied()
    {
        var client = Client(key: null, SiteA);

        var ex = await Assert.ThrowsAsync<RpcException>(
            async () => await client.SubmitNotificationAsync(new NotificationSubmitDto()));

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

    [Fact]
    public async Task WrongKeyForTheSite_IsRejected_WithPermissionDenied()
    {
        // site-a presents site-b's (valid, but wrong-for-this-site) key.
        var client = Client(SiteBKey, SiteA);

        var ex = await Assert.ThrowsAsync<RpcException>(
            async () => await client.SubmitNotificationAsync(new NotificationSubmitDto()));

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

    [Fact]
    public async Task UnknownSite_IsRejected_WithPermissionDenied()
    {
        var client = Client("any-key", "site-nonexistent");

        var ex = await Assert.ThrowsAsync<RpcException>(
            async () => await client.SubmitNotificationAsync(new NotificationSubmitDto()));

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

    [Fact]
    public async Task TheAcceptAndRejectPathsAreDistinguishable()
    {
        // A gate whose accept and reject paths produce the same observable result is not a gate.
        // Correct key → the service answers (Accepted:true); wrong key → PermissionDenied,
        // never reaching the service. These are two different outcomes, which is the whole point.
        var ok = await Client(SiteAKey, SiteA).SubmitNotificationAsync(NewNotificationDto());
        Assert.True(ok.Accepted);

        var ex = await Assert.ThrowsAsync<RpcException>(
            async () => await Client("wrong", SiteA).SubmitNotificationAsync(new NotificationSubmitDto()));
        Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
    }

    // ---- One RPC per shape: unary (above) + the ingest bridge ----

    [Fact]
    public async Task IngestAuditEvents_DecodesTheBatch_AsksTheIngestProxy_EncodesTheAck()
    {
        var client = Client(SiteAKey, SiteA);

        var batch = new AuditEventBatch();
        batch.Events.Add(NewAuditDto());
        batch.Events.Add(NewAuditDto());

        var ack = await client.IngestAuditEventsAsync(batch);

        // The stub actor accepts one deterministic id per non-empty batch; its presence proves
        // the full DTO→command→Ask→reply→ack bridge ran straight to the ingest proxy, gated
        // call and all.
        Assert.Contains(AcceptedId.ToString(), ack.AcceptedEventIds);
    }

    [Fact]
    public async Task IngestAuditEvents_EmptyBatch_ShortCircuits_WithoutAskingTheIngestProxy()
    {
        // Even the empty-batch fast path is behind the gate — it still needs a valid key.
        var client = Client(SiteAKey, SiteA);

        var ack = await client.IngestAuditEventsAsync(new AuditEventBatch());

        Assert.Empty(ack.AcceptedEventIds);
    }

    // The mapper reads SiteEnqueuedAt unconditionally; only DTOs that clear the gate reach it,
    // so the accept-path tests carry a timestamp while the negative tests can pass a bare DTO.
    private static NotificationSubmitDto NewNotificationDto() => new()
    {
        NotificationId = Guid.NewGuid().ToString(),
        ListName = "ops",
        SiteEnqueuedAt = Timestamp.FromDateTime(
            DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
    };

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

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

    private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
    {
        public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
            => keys.TryGetValue(siteId, out var key)
                ? new ValueTask<string>(key)
                : throw new InvalidOperationException($"no key for '{siteId}'");

        public void Invalidate(string siteId) { }
    }

    /// <summary>
    /// Minimal stand-in for both actors the service Asks — <c>CentralCommunicationActor</c> for
    /// the unary control RPCs and the <c>audit-log-ingest</c> singleton for the ingest ones.
    /// Replies straight to the Ask's temp sender, exactly as the real actors do.
    /// </summary>
    private sealed class StubCentralActor : ReceiveActor
    {
        public StubCentralActor(Guid acceptedId)
        {
            Receive<NotificationSubmit>(msg =>
                Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));

            Receive<IngestAuditEventsCommand>(_ =>
                Sender.Tell(new IngestAuditEventsReply(new[] { acceptedId })));
        }
    }
}
