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.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

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

/// <summary>
/// End-to-end proof that the two halves of the control-plane PSK actually interoperate:
/// <see cref="ControlPlaneCredentials"/> on the client and
/// <see cref="ControlPlaneAuthInterceptor"/> on the server, over a real gRPC stack.
/// </summary>
/// <remarks>
/// <para>
/// The unit tests either side of this file each test one half against a hand-built input, and
/// would both stay green if the halves disagreed — if the credentials never attached to a
/// streaming call, if the metadata key case differed, or if attaching call credentials to a
/// plaintext channel were rejected outright (gRPC refuses that by default; the code opts in with
/// <c>UnsafeUseInsecureChannelCallCredentials</c>, and nothing but a real call proves the opt-in
/// works). Getting that wrong takes down every site's streaming and audit-pull path at once,
/// which is a bad thing to discover on the rig.
/// </para>
/// <para>
/// Runs entirely in-process over <see cref="TestServer"/>: no ports, no containers. The service
/// is a stub rather than the real <c>SiteStreamGrpcServer</c> — this is a test of the auth
/// pipeline, and the real server would drag in an actor system for no added coverage. The method
/// paths and message types are the real generated ones.
/// </para>
/// </remarks>
public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
{
    private IHost _host = null!;
    private TestServer _server = null!;

    /// <summary>Boots the in-process gRPC host with the real interceptor.</summary>
    public async Task InitializeAsync()
    {
        _host = await new HostBuilder()
            .ConfigureWebHost(web => web
                .UseTestServer()
                .ConfigureServices(services =>
                {
                    // Registered exactly as Program.cs does: by TYPE on AddGrpc, with the
                    // interceptor itself NOT in DI. That is load-bearing. An earlier version of
                    // this test added it as a singleton, which let DI hand back the instance and
                    // bypassed Grpc.AspNetCore's own activation — hiding a defect where the
                    // interceptor had two public constructors and
                    // InterceptorRegistration.GetFactory() threw on every single call. The rig
                    // caught it; this test did not. Do not pre-register it.
                    services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>());
                    services.AddSingleton(Options.Create(
                        new CommunicationOptions { GrpcPsk = SiteKey }));
                    services.AddSingleton<EchoSiteStreamService>();
                })
                .Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(e => e.MapGrpcService<EchoSiteStreamService>());
                }))
            .StartAsync();

        _server = _host.GetTestServer();
    }

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

    private const string SiteKey = "the-site-a-preshared-key";

    /// <summary>
    /// Builds a channel through the test server, credentialed exactly as production does.
    /// </summary>
    private GrpcChannel Channel(string? key, string siteId = "site-a")
    {
        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 sealed class FixedPskProvider(string key) : ISitePskProvider
    {
        public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
        public void Invalidate(string siteId) { }
    }

    /// <summary>Stub service: echoes back what the auth pipeline let through.</summary>
    private sealed class EchoSiteStreamService : SiteStreamService.SiteStreamServiceBase
    {
        /// <summary>The site header the last accepted call carried.</summary>
        public string? LastSiteHeader { get; private set; }

        public override Task<PullAuditEventsResponse> PullAuditEvents(
            PullAuditEventsRequest request, ServerCallContext context)
        {
            LastSiteHeader = context.RequestHeaders
                .FirstOrDefault(h => h.Key == ControlPlaneCredentials.SiteHeader)?.Value;
            return Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false });
        }

        public override async Task SubscribeInstance(
            InstanceStreamRequest request,
            IServerStreamWriter<SiteStreamEvent> responseStream,
            ServerCallContext context)
        {
            await responseStream.WriteAsync(new SiteStreamEvent { CorrelationId = request.CorrelationId });
        }
    }

    [Fact]
    public async Task CorrectKey_IsAccepted_OnAUnaryCall()
    {
        using var channel = Channel(SiteKey);
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

        var reply = await client.PullAuditEventsAsync(new PullAuditEventsRequest { BatchSize = 1 });

        Assert.False(reply.MoreAvailable);
    }

    [Fact]
    public async Task WrongKey_IsRejected_WithPermissionDenied()
    {
        using var channel = Channel("some-other-sites-key");
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

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

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

    [Fact]
    public async Task NoCredentialsAtAll_IsRejected()
    {
        // The pre-T0.3 client shape. This is the case that proves the gap is actually closed.
        using var channel = Channel(key: null);
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

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

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

    [Fact]
    public async Task CredentialsApplyToStreamingCalls_NotJustUnaryOnes()
    {
        // CallCredentials cover every call on the channel; a client interceptor that only
        // handled the unary path would pass the test above and still break every subscription.
        using var channel = Channel(SiteKey);
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

        using var call = client.SubscribeInstance(
            new InstanceStreamRequest { CorrelationId = "c1", InstanceUniqueName = "i1" });

        Assert.True(await call.ResponseStream.MoveNext(CancellationToken.None));
        Assert.Equal("c1", call.ResponseStream.Current.CorrelationId);
    }

    [Fact]
    public async Task WrongKey_IsRejected_OnStreamingCallsToo()
    {
        using var channel = Channel("wrong");
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

        using var call = client.SubscribeInstance(new InstanceStreamRequest { CorrelationId = "c1" });

        var ex = await Assert.ThrowsAsync<RpcException>(
            async () => await call.ResponseStream.MoveNext(CancellationToken.None));

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

    [Fact]
    public async Task AnUnresolvableKey_FailsTheCall_RatherThanDialingWithoutOne()
    {
        // SitePskProvider throws when a site has no key anywhere. What matters here is that the
        // throw stops the call: the alternative — swallowing it and sending the request
        // unauthenticated — is the exact failure this design exists to prevent. The status code
        // is gRPC's choice, so assert the RpcException and record what it actually is rather
        // than pinning a guess: callers already treat every non-OK status as a failed call, and
        // the diagnosable signal is SitePskProvider's own LogError, not this code.
        var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() }
            .WithSiteCredentials(new ThrowingPskProvider(), "site-a");
        using var channel = GrpcChannel.ForAddress(_server.BaseAddress, options);
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

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

        Assert.NotEqual(StatusCode.OK, ex.StatusCode);

        // And nothing reached the service.
        Assert.Null(_host.Services.GetRequiredService<EchoSiteStreamService>().LastSiteHeader);
    }

    private sealed class ThrowingPskProvider : ISitePskProvider
    {
        public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
            => throw new InvalidOperationException($"no key for '{siteId}'");
        public void Invalidate(string siteId) { }
    }

    [Fact]
    public async Task TheSiteHeaderTravels_SoCentralCanPickAPerSiteKeyInPhase1A()
    {
        // Central's own interceptor (T1A.2) verifies against the key for the site named in this
        // header. Shipping it now means Phase 1A adds a lookup, not a wire change.
        using var channel = Channel(SiteKey, siteId: "site-a");
        var client = new SiteStreamService.SiteStreamServiceClient(channel);

        await client.PullAuditEventsAsync(new PullAuditEventsRequest());

        var service = _host.Services.GetRequiredService<EchoSiteStreamService>();
        Assert.Equal("site-a", service.LastSiteHeader);
    }
}
