using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.Secrets.Abstractions;

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

/// <summary>
/// Central's per-site gRPC preshared key resolution (ClusterClient→gRPC migration, T0.3).
/// </summary>
/// <remarks>
/// The property that matters most here is the negative one: a site whose key cannot be found
/// must produce a throw, never an unauthenticated channel. Everything else — the two sources,
/// the caching, the invalidation — exists to make that behaviour usable in practice.
/// </remarks>
public class SitePskProviderTests
{
    private static SitePskProvider Create(
        ISecretResolver resolver, CommunicationOptions? options = null)
        => new(
            resolver,
            new StaticOptionsMonitor(options ?? new CommunicationOptions()),
            NullLogger<SitePskProvider>.Instance);

    private sealed class StaticOptionsMonitor(CommunicationOptions value)
        : IOptionsMonitor<CommunicationOptions>
    {
        public CommunicationOptions CurrentValue => value;
        public CommunicationOptions Get(string? name) => value;
        public IDisposable? OnChange(Action<CommunicationOptions, string?> listener) => null;
    }

    [Fact]
    public async Task ResolvesFromTheSecretStore_UnderTheSiteQualifiedName()
    {
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any<CancellationToken>())
            .Returns("key-for-a");

        var psk = await Create(resolver).GetAsync("site-a", CancellationToken.None);

        Assert.Equal("key-for-a", psk);
    }

    [Fact]
    public async Task ConfiguredMapWins_SoAHostWithNoMasterKeyCanStillDial()
    {
        // The development rig runs with no secrets master key at all — every credential
        // arrives as an environment override. Without this source, the rig could not use the
        // gated control plane and the fail-closed design would be untestable there.
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
            .Returns("from-the-store");

        var options = new CommunicationOptions();
        options.SitePsks["site-a"] = "from-config";

        var psk = await Create(resolver, options).GetAsync("site-a", CancellationToken.None);

        Assert.Equal("from-config", psk);
        await resolver.DidNotReceive().GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task StoreIsStillConsulted_ForASiteMissingFromTheMap()
    {
        // Sites are added at runtime from the Central UI, so the map can never be complete.
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any<CancellationToken>())
            .Returns("key-for-b");

        var options = new CommunicationOptions();
        options.SitePsks["site-a"] = "from-config";

        var psk = await Create(resolver, options).GetAsync("site-b", CancellationToken.None);

        Assert.Equal("key-for-b", psk);
    }

    [Fact]
    public async Task MissingSecret_Throws_AndNeverYieldsAnEmptyKey()
    {
        // Fail-closed. The alternative — returning "" — would build a channel that presents
        // "Bearer " and gets PermissionDenied anyway, but with a far less diagnosable error.
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
            .Returns((string?)null);

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));

        // The message must name both sources — either one fixes it.
        Assert.Contains("SB-GRPC-PSK-site-a", ex.Message);
        Assert.Contains("SitePsks", ex.Message);
    }

    [Fact]
    public async Task EmptySecret_IsTreatedAsMissing()
    {
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).Returns("");

        await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
    }

    [Fact]
    public async Task AFaultingStore_SurfacesAsKeyNotConfigured_NotAsACryptoError()
    {
        // A host with no master key throws from the resolver. The operator's problem is the
        // same either way — "this site has no key" — so the diagnosis must say that.
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
            .Returns<string?>(_ => throw new InvalidOperationException("no master key configured"));

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));

        Assert.Contains("SB-GRPC-PSK-site-a", ex.Message);
    }

    [Fact]
    public async Task SuccessfulResolvesAreCached_SoEveryCallDoesNotHitTheStore()
    {
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).Returns("k");
        var provider = Create(resolver);

        await provider.GetAsync("site-a", CancellationToken.None);
        await provider.GetAsync("site-a", CancellationToken.None);
        await provider.GetAsync("site-a", CancellationToken.None);

        await resolver.Received(1).GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task FailuresAreNotCached_SoASeededKeyIsPickedUpWithoutARestart()
    {
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
            .Returns((string?)null, "seeded-later");
        var provider = Create(resolver);

        await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await provider.GetAsync("site-a", CancellationToken.None));

        Assert.Equal("seeded-later", await provider.GetAsync("site-a", CancellationToken.None));
    }

    [Fact]
    public async Task Invalidate_DropsTheCachedKey_SoARotatedKeyIsRead()
    {
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
            .Returns("old", "rotated");
        var provider = Create(resolver);

        Assert.Equal("old", await provider.GetAsync("site-a", CancellationToken.None));
        provider.Invalidate("site-a");

        Assert.Equal("rotated", await provider.GetAsync("site-a", CancellationToken.None));
    }

    [Fact]
    public async Task KeysAreScopedPerSite_SoOneSiteNeverPresentsAnothersKey()
    {
        // The whole reason the design rejected a single fleet-wide key: blast radius.
        var resolver = Substitute.For<ISecretResolver>();
        resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any<CancellationToken>())
            .Returns("key-a");
        resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any<CancellationToken>())
            .Returns("key-b");
        var provider = Create(resolver);

        Assert.Equal("key-a", await provider.GetAsync("site-a", CancellationToken.None));
        Assert.Equal("key-b", await provider.GetAsync("site-b", CancellationToken.None));
    }
}
