using System.Security.Cryptography;
using System.Text;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

namespace ZB.MOM.WW.ScadaBridge.Host;

/// <summary>
/// Gates the site↔central gRPC control plane with a preshared key.
/// </summary>
/// <remarks>
/// <para>
/// <b>The gap this closes.</b> <c>SiteStreamService</c> shipped with no authentication at all —
/// plaintext h2c, no interceptor. Anything that could reach a site node's gRPC port could open a
/// live data stream or call <c>PullAuditEvents</c>/<c>PullSiteCalls</c> and read audit rows back.
/// The only gated surface on that listener was LocalDb sync, and only for its own service. That
/// gap exists independently of the ClusterClient→gRPC migration; it becomes indefensible once
/// every command crosses this listener.
/// </para>
/// <para>
/// <b>Modeled on <see cref="LocalDbSyncAuthInterceptor"/>,</b> deliberately: same four server
/// handlers funnelling into one <c>Authorize</c>, same <c>authorization: Bearer</c> extraction,
/// same <see cref="CryptographicOperations.FixedTimeEquals"/> comparison, same fail-closed
/// posture, same <see cref="StatusCode.PermissionDenied"/> rejection. Two differences:
/// </para>
/// <list type="number">
/// <item>It gates a <b>set</b> of service prefixes rather than one, so later phases can add the
/// new command/control services without a second interceptor.</item>
/// <item>Its expected key comes from <see cref="CommunicationOptions.GrpcPsk"/> — the site's own
/// key, supplied in production as <c>${secret:SB-GRPC-PSK-&lt;siteId&gt;}</c> and expanded before
/// the host is built.</item>
/// </list>
/// <para>
/// <b>The two keys are separate on purpose.</b> LocalDb sync keeps its own
/// <c>LocalDb:Replication:ApiKey</c>, which authenticates a different peer (the pair partner, not
/// central) over a different trust relationship. Sharing one key would mean a site's central-facing
/// key also admits writes into its database.
/// </para>
/// <para>
/// <b>Fail-closed, and not optional.</b> With no <c>GrpcPsk</c> configured, every gated call is
/// rejected — including the ones that work today. That is a deliberate break: LocalDb replication
/// is an opt-in feature whose "off" state is "no peer", whereas streaming and audit pull are
/// core paths, so "no key" must not silently mean "no authentication". Every environment must
/// carry a key before upgrading to this build.
/// </para>
/// </remarks>
public sealed class ControlPlaneAuthInterceptor : Interceptor
{
    /// <summary>
    /// Service prefixes gated by default. Read from the generated service descriptors —
    /// <c>package sitestream; service SiteStreamService</c> (real-time data + audit pull) and
    /// <c>package scadabridge.sitecommand.v1; service SiteCommandService</c> (the T1B command
    /// plane). Later phases append their own services here rather than adding a second
    /// interceptor or constructor (see the public constructor's remarks).
    /// </summary>
    public static readonly IReadOnlyList<string> DefaultGatedPrefixes =
        new[]
        {
            $"/{SiteStreamService.Descriptor.FullName}/",
            $"/{SiteCommandService.Descriptor.FullName}/",
        };

    private readonly IReadOnlyList<string> _gatedPrefixes;
    private readonly IOptions<CommunicationOptions> _options;
    private readonly ILogger<ControlPlaneAuthInterceptor> _logger;

    /// <summary>
    /// Creates the interceptor gating <see cref="DefaultGatedPrefixes"/>.
    /// </summary>
    /// <remarks>
    /// <b>This must remain the ONLY public constructor.</b> <c>AddGrpc</c> registers the
    /// interceptor by type, and <c>Grpc.AspNetCore.Server.InterceptorRegistration.GetFactory()</c>
    /// throws <c>"Multiple constructors accepting all given argument types have been found"</c>
    /// when a second one is applicable. That throw happens per call, inside the pipeline, and
    /// surfaces to the caller as <c>Unknown / "Exception was thrown by handler"</c> — so the gate
    /// silently stops authorizing anything while still failing every call. A second public
    /// constructor added here in a later phase reintroduces exactly that. Pinned by
    /// <c>ControlPlaneAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor</c>.
    /// </remarks>
    /// <param name="options">Communication options; <c>GrpcPsk</c> is the expected bearer token.</param>
    /// <param name="logger">Logger for denial diagnostics.</param>
    public ControlPlaneAuthInterceptor(
        IOptions<CommunicationOptions> options,
        ILogger<ControlPlaneAuthInterceptor> logger)
        : this(options, logger, DefaultGatedPrefixes)
    {
    }

    /// <summary>
    /// Creates the interceptor gating an explicit set of service prefixes. <b>Internal</b> —
    /// see the public constructor's remarks for why this cannot be public. Phases that add a
    /// service to the gate should extend <see cref="DefaultGatedPrefixes"/> rather than reach
    /// for a second registration shape.
    /// </summary>
    /// <param name="options">Communication options; <c>GrpcPsk</c> is the expected bearer token.</param>
    /// <param name="logger">Logger for denial diagnostics.</param>
    /// <param name="gatedPrefixes">Method-path prefixes to gate, e.g. <c>/sitestream.SiteStreamService/</c>.</param>
    internal ControlPlaneAuthInterceptor(
        IOptions<CommunicationOptions> options,
        ILogger<ControlPlaneAuthInterceptor> logger,
        IReadOnlyList<string> gatedPrefixes)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentNullException.ThrowIfNull(logger);
        ArgumentNullException.ThrowIfNull(gatedPrefixes);

        _options = options;
        _logger = logger;
        _gatedPrefixes = gatedPrefixes;
    }

    /// <inheritdoc />
    public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        Authorize(context);
        return continuation(request, context);
    }

    /// <inheritdoc />
    public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
        IAsyncStreamReader<TRequest> requestStream,
        IServerStreamWriter<TResponse> responseStream,
        ServerCallContext context,
        DuplexStreamingServerMethod<TRequest, TResponse> continuation)
    {
        Authorize(context);
        return continuation(requestStream, responseStream, context);
    }

    /// <inheritdoc />
    public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
        IAsyncStreamReader<TRequest> requestStream,
        ServerCallContext context,
        ClientStreamingServerMethod<TRequest, TResponse> continuation)
    {
        Authorize(context);
        return continuation(requestStream, context);
    }

    /// <inheritdoc />
    public override Task ServerStreamingServerHandler<TRequest, TResponse>(
        TRequest request,
        IServerStreamWriter<TResponse> responseStream,
        ServerCallContext context,
        ServerStreamingServerMethod<TRequest, TResponse> continuation)
    {
        Authorize(context);
        return continuation(request, responseStream, context);
    }

    /// <summary>
    /// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> if this
    /// is a gated call that does not carry the configured bearer token. Calls to services
    /// outside <c>gatedPrefixes</c> — notably LocalDb sync, which has its own interceptor —
    /// return immediately.
    /// </summary>
    private void Authorize(ServerCallContext context)
    {
        if (!IsGated(context.Method))
        {
            return;
        }

        var expected = _options.Value.GrpcPsk;
        if (string.IsNullOrEmpty(expected))
        {
            _logger.LogWarning(
                "Rejected a control-plane call to {Method}: no ScadaBridge:Communication:GrpcPsk is "
                + "configured, so the control plane is closed. Set the same key here (in production, "
                + "as ${{secret:SB-GRPC-PSK-<siteId>}}) and in central's secret store.",
                context.Method);
            throw new RpcException(new Status(
                StatusCode.PermissionDenied,
                "Control plane is not accepting calls: no preshared key is configured on this node."));
        }

        var presented = ExtractBearerToken(context.RequestHeaders);
        if (presented is null || !FixedTimeEquals(presented, expected))
        {
            _logger.LogWarning(
                "Rejected a control-plane call to {Method}: {Reason}.",
                context.Method,
                presented is null ? "no bearer token presented" : "bearer token did not match");
            throw new RpcException(new Status(
                StatusCode.PermissionDenied,
                "Control plane authentication failed."));
        }
    }

    private bool IsGated(string method)
    {
        foreach (var prefix in _gatedPrefixes)
        {
            if (method.StartsWith(prefix, StringComparison.Ordinal))
            {
                return true;
            }
        }
        return false;
    }

    private static string? ExtractBearerToken(Metadata headers)
    {
        // gRPC lowercases header keys on the wire; compare case-insensitively anyway so a
        // hand-built Metadata in a test behaves the same as a real request.
        foreach (var entry in headers)
        {
            if (!string.Equals(entry.Key, ControlPlaneCredentials.AuthorizationHeader,
                    StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var value = entry.Value;
            if (value is not null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
            {
                return value["Bearer ".Length..];
            }
        }

        return null;
    }

    private static bool FixedTimeEquals(string presented, string expected)
        => CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
}
