using Grpc.Core;
using Grpc.Net.Client;

namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;

/// <summary>
/// Resolves the preshared key that authenticates gRPC control-plane traffic for one site
/// relationship. Central holds one key per site; each site holds only its own.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why per-site rather than one fleet-wide key.</b> A compromised site yields only its own
/// key, never another site's. A single shared key would be simpler to seed and strictly worse
/// on blast radius, which is why it was rejected in the design.
/// </para>
/// <para>
/// <b>Fail-closed.</b> Implementations throw when the key cannot be resolved. They must never
/// fall back to "no key means no authentication" — that is the failure mode this whole
/// mechanism exists to remove, and it would silently disable auth on exactly the default
/// configuration. A dial that cannot be authenticated does not happen.
/// </para>
/// <para>
/// The interface lives in Communication (not Host) because both sides need it: central's
/// site-dialing clients live here and in AuditLog, while the implementation over
/// <c>ISecretResolver</c> lives in Host, which owns the secrets container.
/// </para>
/// </remarks>
public interface ISitePskProvider
{
    /// <summary>
    /// Resolves the preshared key for <paramref name="siteId"/>, caching the result.
    /// </summary>
    /// <param name="siteId">Site identifier, as used in the <c>Site.SiteIdentifier</c> column.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>The preshared key. Never null or empty.</returns>
    /// <exception cref="InvalidOperationException">
    /// The key is not configured, not resolvable, or empty — the fail-closed path.
    /// </exception>
    ValueTask<string> GetAsync(string siteId, CancellationToken ct);

    /// <summary>
    /// Drops any cached key for <paramref name="siteId"/>, so the next
    /// <see cref="GetAsync"/> re-reads the store. Called when a site is removed, and after a
    /// key rotation.
    /// </summary>
    /// <param name="siteId">Site identifier whose cached key should be discarded.</param>
    void Invalidate(string siteId);
}

/// <summary>
/// Builds the call credentials that carry a site's preshared key (and the site's own identity)
/// on every gRPC call central makes to that site — and, from Phase 1A, on the calls a site makes
/// to central.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why <see cref="CallCredentials.FromInterceptor(AsyncAuthInterceptor)"/> rather than a
/// client <c>Interceptor</c>.</b> The key is resolved asynchronously from the secrets store, and
/// this is the one extension point in gRPC that is async by design. A client interceptor would
/// have to block on the resolve inside a synchronous <c>AsyncServerStreamingCall</c> path.
/// Credentials also apply uniformly to unary and streaming calls, so no call site can forget one.
/// </para>
/// <para>
/// <b>Why <c>UnsafeUseInsecureChannelCallCredentials</c>.</b> gRPC refuses to attach call
/// credentials to a plaintext channel by default, precisely because a bearer token on h2c is
/// readable and replayable by anyone on the path. That is a real and accepted limitation here:
/// these listeners are h2c today and the boundary assumes a trusted network. The PSK raises the
/// bar from "anyone who can reach the port" to "anyone who can read the traffic"; TLS on these
/// listeners is the follow-on hardening and requires no change to this code.
/// </para>
/// </remarks>
public static class ControlPlaneCredentials
{
    /// <summary>
    /// Metadata header naming the site a call belongs to. Central needs it to pick which
    /// per-site key to verify against; a site's own inbound gate ignores it (a site has exactly
    /// one key). Required by central's interceptor from Phase 1A.
    /// </summary>
    public const string SiteHeader = "x-scadabridge-site";

    /// <summary>The bearer metadata header. Lowercase — gRPC lowercases header keys on the wire.</summary>
    public const string AuthorizationHeader = "authorization";

    /// <summary>
    /// Creates call credentials that attach <c>authorization: Bearer &lt;psk&gt;</c> and
    /// <c>x-scadabridge-site: &lt;siteId&gt;</c> to every call.
    /// </summary>
    /// <param name="provider">Resolves the site's preshared key.</param>
    /// <param name="siteId">The site this channel talks to (or, site-side, this site's own id).</param>
    /// <returns>Call credentials for a channel bound to that site.</returns>
    public static CallCredentials ForSite(ISitePskProvider provider, string siteId)
    {
        ArgumentNullException.ThrowIfNull(provider);
        ArgumentException.ThrowIfNullOrWhiteSpace(siteId);

        return CallCredentials.FromInterceptor(async (context, metadata) =>
        {
            // A throw here fails the call, which is the point: an unauthenticated dial must
            // not happen. Callers classify the resulting fault the same way they classify any
            // other — the pull clients degrade to an empty batch and log, the streaming
            // subscribers retry.
            var psk = await provider.GetAsync(siteId, context.CancellationToken).ConfigureAwait(false);
            metadata.Add(AuthorizationHeader, $"Bearer {psk}");
            metadata.Add(SiteHeader, siteId);
        });
    }

    /// <summary>
    /// Applies per-site call credentials to channel options, if a provider is available.
    /// A null provider leaves the options untouched — the shape used by test-only and
    /// default constructors that never dial a gated endpoint.
    /// </summary>
    /// <param name="options">Channel options being built.</param>
    /// <param name="provider">Key provider, or null to leave the channel unauthenticated.</param>
    /// <param name="siteId">The site this channel talks to.</param>
    /// <returns>The same options instance, for chaining.</returns>
    public static GrpcChannelOptions WithSiteCredentials(
        this GrpcChannelOptions options, ISitePskProvider? provider, string? siteId)
    {
        ArgumentNullException.ThrowIfNull(options);

        if (provider is null || string.IsNullOrWhiteSpace(siteId))
        {
            return options;
        }

        options.Credentials = ChannelCredentials.Create(
            ChannelCredentials.Insecure, ForSite(provider, siteId));
        options.UnsafeUseInsecureChannelCallCredentials = true;
        return options;
    }
}
