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

namespace ZB.MOM.WW.ScadaBridge.Host;

/// <summary>
/// Gates the central-hosted gRPC control plane (<c>CentralControlService</c>) with each site's
/// preshared key. The sibling of <see cref="ControlPlaneAuthInterceptor"/>, but with the
/// verification model inverted: a site checks one bearer token against its own single key, whereas
/// central must check the presented token against the key belonging to the SITE that sent it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a separate class rather than a second constructor on
/// <see cref="ControlPlaneAuthInterceptor"/>.</b> <c>Grpc.AspNetCore</c> registers a
/// type-registered interceptor through <c>InterceptorRegistration.GetFactory()</c>, which throws
/// <c>"Multiple constructors accepting all given argument types have been found"</c> the moment a
/// second public constructor is applicable. That throw lands inside the pipeline on every call and
/// surfaces as <c>Unknown / "Exception was thrown by handler"</c> — the node boots healthy and
/// every gated call dies looking like a handler bug, with correct/wrong/no key all producing the
/// identical error. It shipped once with a fully green suite and was caught only on the rig.
/// Central's model genuinely differs (per-site key by header, not the site's one own-key), so it
/// gets its own class with its own single public constructor rather than a variant ctor on the
/// site interceptor. Both classes are pinned by a reflection test asserting exactly one public
/// constructor.
/// </para>
/// <para>
/// <b>Fail-closed on every branch.</b> A gated call is refused with
/// <see cref="StatusCode.PermissionDenied"/> when: the <c>x-scadabridge-site</c> header is
/// missing or blank; no key can be resolved for that site (<see cref="ISitePskProvider"/> throws);
/// or the presented bearer token does not match. There is no pass-through — an unresolvable or
/// absent identity never degrades to "let it in". Non-gated services (should any share the
/// listener) return immediately, matching the site interceptor's shape.
/// </para>
/// <para>
/// The comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> over UTF-8, the same
/// constant-time compare the site interceptor and LocalDb sync use.
/// </para>
/// </remarks>
public sealed class CentralControlAuthInterceptor : Interceptor
{
    /// <summary>
    /// Service prefixes gated by default — the one central-hosted control-plane service. Taken
    /// from the generated <c>package scadabridge.centralcontrol.v1; service CentralControlService</c>.
    /// </summary>
    public static readonly IReadOnlyList<string> DefaultGatedPrefixes =
        new[] { $"/{CentralControlService.Descriptor.FullName}/" };

    private readonly IReadOnlyList<string> _gatedPrefixes;
    private readonly ISitePskProvider _pskProvider;
    private readonly ILogger<CentralControlAuthInterceptor> _logger;

    /// <summary>
    /// Creates the interceptor gating <see cref="DefaultGatedPrefixes"/>.
    /// </summary>
    /// <remarks>
    /// <b>This must remain the ONLY public constructor</b> — see the class remarks for why a
    /// second one silently disables the gate. Pinned by
    /// <c>CentralControlAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor</c>.
    /// </remarks>
    /// <param name="pskProvider">Resolves each site's preshared key.</param>
    /// <param name="logger">Logger for denial diagnostics.</param>
    public CentralControlAuthInterceptor(
        ISitePskProvider pskProvider,
        ILogger<CentralControlAuthInterceptor> logger)
        : this(pskProvider, logger, DefaultGatedPrefixes)
    {
    }

    /// <summary>
    /// Creates the interceptor gating an explicit prefix set. <b>Internal</b> — a public second
    /// constructor would reintroduce the ambiguous-constructor defect described on the class.
    /// </summary>
    /// <param name="pskProvider">Resolves each site's preshared key.</param>
    /// <param name="logger">Logger for denial diagnostics.</param>
    /// <param name="gatedPrefixes">Method-path prefixes to gate.</param>
    internal CentralControlAuthInterceptor(
        ISitePskProvider pskProvider,
        ILogger<CentralControlAuthInterceptor> logger,
        IReadOnlyList<string> gatedPrefixes)
    {
        ArgumentNullException.ThrowIfNull(pskProvider);
        ArgumentNullException.ThrowIfNull(logger);
        ArgumentNullException.ThrowIfNull(gatedPrefixes);

        _pskProvider = pskProvider;
        _logger = logger;
        _gatedPrefixes = gatedPrefixes;
    }

    /// <inheritdoc />
    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        await AuthorizeAsync(context).ConfigureAwait(false);
        return await continuation(request, context).ConfigureAwait(false);
    }

    /// <inheritdoc />
    public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
        IAsyncStreamReader<TRequest> requestStream,
        IServerStreamWriter<TResponse> responseStream,
        ServerCallContext context,
        DuplexStreamingServerMethod<TRequest, TResponse> continuation)
    {
        await AuthorizeAsync(context).ConfigureAwait(false);
        await continuation(requestStream, responseStream, context).ConfigureAwait(false);
    }

    /// <inheritdoc />
    public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
        IAsyncStreamReader<TRequest> requestStream,
        ServerCallContext context,
        ClientStreamingServerMethod<TRequest, TResponse> continuation)
    {
        await AuthorizeAsync(context).ConfigureAwait(false);
        return await continuation(requestStream, context).ConfigureAwait(false);
    }

    /// <inheritdoc />
    public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
        TRequest request,
        IServerStreamWriter<TResponse> responseStream,
        ServerCallContext context,
        ServerStreamingServerMethod<TRequest, TResponse> continuation)
    {
        await AuthorizeAsync(context).ConfigureAwait(false);
        await continuation(request, responseStream, context).ConfigureAwait(false);
    }

    /// <summary>
    /// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> unless a
    /// gated call carries a valid <c>x-scadabridge-site</c> header AND a bearer token matching
    /// that site's resolved key. Non-gated calls return immediately.
    /// </summary>
    private async Task AuthorizeAsync(ServerCallContext context)
    {
        if (!IsGated(context.Method))
        {
            return;
        }

        var siteId = ExtractSiteId(context.RequestHeaders);
        if (string.IsNullOrWhiteSpace(siteId))
        {
            _logger.LogWarning(
                "Rejected a central control-plane call to {Method}: the required "
                + "'{Header}' metadata header is missing or blank, so there is no per-site key to "
                + "verify against.",
                context.Method, ControlPlaneCredentials.SiteHeader);
            throw Denied("missing site identity header");
        }

        string expected;
        try
        {
            expected = await _pskProvider.GetAsync(siteId, context.CancellationToken)
                .ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            // Fail-closed: an unresolvable key is a denial, never a pass-through. The provider
            // has already logged the specific cause (missing secret / missing config entry).
            _logger.LogWarning(ex,
                "Rejected a central control-plane call to {Method}: no preshared key could be "
                + "resolved for site {SiteId}.",
                context.Method, siteId);
            throw Denied("no key configured for the presented site");
        }

        var presented = ExtractBearerToken(context.RequestHeaders);
        if (presented is null || !FixedTimeEquals(presented, expected))
        {
            _logger.LogWarning(
                "Rejected a central control-plane call to {Method} from site {SiteId}: {Reason}.",
                context.Method, siteId,
                presented is null ? "no bearer token presented" : "bearer token did not match");
            throw Denied("control plane authentication failed");
        }
    }

    private static RpcException Denied(string reason)
        => new(new Status(StatusCode.PermissionDenied, $"Control plane authentication failed: {reason}."));

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

    private static string? ExtractSiteId(Metadata headers)
    {
        foreach (var entry in headers)
        {
            if (string.Equals(entry.Key, ControlPlaneCredentials.SiteHeader,
                    StringComparison.OrdinalIgnoreCase))
            {
                return entry.Value;
            }
        }
        return null;
    }

    private static string? ExtractBearerToken(Metadata headers)
    {
        // gRPC lowercases header keys on the wire; compare case-insensitively 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));
}
