using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

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

/// <summary>
/// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of
/// <see cref="ControlPlaneAuthInterceptorTests"/>, but central's verification model is inverted:
/// it looks up the key for the site named in the <c>x-scadabridge-site</c> header and checks the
/// bearer token against THAT, rather than against one own-key.
/// </summary>
/// <remarks>
/// Central genuinely needs a distinct verification model, so it is a separate class with its own
/// single public constructor — not a variant ctor on <see cref="ControlPlaneAuthInterceptor"/>,
/// whose two-public-constructor form once silently disabled the gate. The one-public-ctor
/// invariant is pinned below exactly as the sibling pins it.
/// </remarks>
public class CentralControlAuthInterceptorTests
{
    private const string ControlMethod =
        "/scadabridge.centralcontrol.v1.CentralControlService/SubmitNotification";
    private const string SiteStreamMethod = "/sitestream.SiteStreamService/SubscribeInstance";

    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";

    /// <summary>An <see cref="ISitePskProvider"/> backed by a fixed site→key map; throws for unknown sites.</summary>
    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) { }
    }

    private static CentralControlAuthInterceptor CreateInterceptor()
        => new(
            new MapPskProvider(new Dictionary<string, string> { [SiteA] = SiteAKey, [SiteB] = SiteBKey }),
            NullLogger<CentralControlAuthInterceptor>.Instance);

    private static ServerCallContext CreateContext(
        string method, string? siteHeader, string? authorizationHeader)
    {
        var headers = new Metadata();
        if (siteHeader is not null)
            headers.Add(ControlPlaneCredentials.SiteHeader, siteHeader);
        if (authorizationHeader is not null)
            headers.Add(ControlPlaneCredentials.AuthorizationHeader, authorizationHeader);

        return new FakeServerCallContext(method, headers);
    }

    private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
        : ServerCallContext
    {
        protected override string MethodCore => method;
        protected override string HostCore => "localhost";
        protected override string PeerCore => "ipv4:127.0.0.1:12345";
        protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
        protected override Metadata RequestHeadersCore => requestHeaders;
        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 { get; } =
            new(null, new Dictionary<string, List<AuthProperty>>());

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

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

    private static Task<string> Invoke(
        CentralControlAuthInterceptor interceptor, ServerCallContext context)
        => interceptor.UnaryServerHandler<string, string>(
            "request", context, (_, _) => Task.FromResult("ok"));

    [Fact]
    public async Task NonGatedMethod_PassesThrough()
    {
        // Central's interceptor gates only CentralControlService; anything else on the listener
        // (there is nothing today) is not its concern.
        var interceptor = CreateInterceptor();
        var context = CreateContext(SiteStreamMethod, siteHeader: null, authorizationHeader: null);

        Assert.Equal("ok", await Invoke(interceptor, context));
    }

    [Fact]
    public async Task CorrectSiteAndKey_IsAccepted()
    {
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteAKey}");

        Assert.Equal("ok", await Invoke(interceptor, context));
    }

    [Fact]
    public async Task MissingSiteHeader_IsDenied()
    {
        // Fail-closed: no header means no per-site key to verify against, so there is nothing to
        // pass through TO. A present bearer token does not rescue it.
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, siteHeader: null, $"Bearer {SiteAKey}");

        var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
        Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
    }

    [Fact]
    public async Task BlankSiteHeader_IsDenied()
    {
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, siteHeader: "   ", $"Bearer {SiteAKey}");

        var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
        Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
    }

    [Fact]
    public async Task UnknownSite_IsDenied_NotPassedThrough()
    {
        // The provider throws for an unknown site; the interceptor must turn that into a denial,
        // never swallow it and let the call proceed unauthenticated.
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, "site-nonexistent", "Bearer whatever");

        var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
        Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
    }

    [Fact]
    public async Task NoBearerToken_IsDenied()
    {
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, SiteA, authorizationHeader: null);

        var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
        Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
    }

    [Fact]
    public async Task WrongKeyForTheSite_IsDenied()
    {
        // site-a presents site-b's key. Both keys are valid keys; the point is the token must
        // match the key for THIS site, not just be a key central knows.
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteBKey}");

        var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
        Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
    }

    [Fact]
    public async Task EachSiteIsVerifiedAgainstItsOwnKey()
    {
        // site-b with site-b's key is accepted by the same interceptor instance that rejected
        // site-a-with-site-b's-key above — proving the per-site lookup, not a single shared key.
        var interceptor = CreateInterceptor();
        var context = CreateContext(ControlMethod, SiteB, $"Bearer {SiteBKey}");

        Assert.Equal("ok", await Invoke(interceptor, context));
    }

    [Fact]
    public void TheInterceptorHasExactlyOnePublicConstructor()
    {
        // Grpc.AspNetCore registers this interceptor BY TYPE; InterceptorRegistration.GetFactory()
        // throws "Multiple constructors accepting all given argument types have been found" the
        // moment a second public constructor is applicable, and the throw lands inside the
        // pipeline on every call — a gate that authorizes nothing while looking like a handler
        // bug. The explicit-prefix constructor is internal to keep it from recurring; this pins
        // that. (Same invariant as ControlPlaneAuthInterceptorTests.)
        var publicCtors = typeof(CentralControlAuthInterceptor).GetConstructors();

        Assert.Single(publicCtors);
    }

    [Fact]
    public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath()
    {
        // A typo here disables the whole gate silently: every call would pass through. Pin it
        // against the generated service descriptor, not the proto text.
        var method = CentralControlService.Descriptor.FullName;
        Assert.Contains(
            CentralControlAuthInterceptor.DefaultGatedPrefixes,
            p => p == $"/{method}/");
    }
}
