using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Replicator.Grpc;
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
using ZB.MOM.WW.Secrets.Sqlite;

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

/// <summary>
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
/// <para>
/// These deliberately BUILD a container and RESOLVE out of it rather than asserting over
/// <see cref="ServiceDescriptor"/>s. A decorator registration can look perfectly correct as a
/// descriptor list and still throw on the first resolve because the undecorated concrete store it
/// depends on is missing — that exact gap shipped once in this library, with every descriptor-level
/// unit test green. Resolution is the only assertion that catches it.
/// </para>
/// <para>
/// Registration must not need a reachable SQL Server: the SQL-Server package validates its options
/// eagerly, but validation only checks that the connection string is non-empty and the schema name
/// is a legal identifier. Nothing connects until a sweep or a write runs, and these tests do
/// neither — so the dummy connection string below never resolves to a real host. The same holds for
/// the gRPC hub client: <c>GrpcChannel.ForAddress</c> performs no I/O, so the dummy endpoint is
/// never dialled either.
/// </para>
/// </summary>
public class SecretsReplicationWiringTests
{
    // Syntactically valid, deliberately unreachable. Never connected to by these tests.
    private const string DummyConnectionString =
        "Server=unused;Database=x;Integrated Security=true;";

    // Syntactically valid, deliberately unreachable. Never dialled by these tests.
    private const string DummyHubEndpoint = "http://unused.invalid:8083";

    // Syntactically valid, deliberately unreachable. Never dialled by these tests.
    private const string DummyFallbackHubEndpoint = "http://unused-fallback.invalid:8083";

    /// <summary>
    /// Keyed service key of the <see cref="GrpcChannel"/> dialing fallback endpoint 0 — the 0.5.0
    /// package contract: the primary channel keeps
    /// <see cref="SecretsGrpcHubClientExtensions.ChannelServiceKey"/> unchanged and fallback
    /// <c>i</c> gets <c>:fallback:i</c> appended.
    /// </summary>
    private const string FallbackChannelKey =
        SecretsGrpcHubClientExtensions.ChannelServiceKey + ":fallback:0";

    /// <summary>
    /// The package-internal reader seam the follower sweep pulls through
    /// (<c>ISecretsHubReader</c>). Internal to the package, so it is obtained by full name and
    /// resolved by <see cref="Type"/> — visibility never gates DI resolution, only compile-time
    /// references.
    /// </summary>
    private static readonly Type HubReaderInterface = typeof(SecretsHubAuthInterceptor).Assembly
        .GetType("ZB.MOM.WW.Secrets.Replicator.Grpc.ISecretsHubReader", throwOnError: true)!;

    // Not a credential — a non-blank placeholder, which is all the fail-closed validators check.
    private const string DummyBearerToken = "test-hub-token";

    private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides)
    {
        var settings = new Dictionary<string, string?>
        {
            ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-test.db"),
            ["Secrets:MasterKey:Source"] = "Environment",
            ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
            ["Secrets:RunMigrationsOnStartup"] = "false",
        };

        foreach ((string key, string value) in overrides)
        {
            settings[key] = value;
        }

        return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
    }

    private static ServiceProvider BuildProvider(
        IConfiguration config,
        SecretsNodeRole role = SecretsNodeRole.Central)
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddScadaBridgeSecrets(config, role);
        return services.BuildServiceProvider(validateScopes: true);
    }

    /// <summary>
    /// Any hosted service contributed by the SQL-Server replicator package. Asserted by assembly
    /// rather than by type because <c>SqlServerHubMigrationHostedService</c> is internal to it.
    /// </summary>
    private static bool IsSqlServerReplicatorHostedService(ServiceDescriptor descriptor) =>
        descriptor.ServiceType == typeof(IHostedService)
        && descriptor.ImplementationType?.Assembly.GetName().Name
            == "ZB.MOM.WW.Secrets.Replicator.SqlServer";

    /// <summary>
    /// Any descriptor whose service OR implementation type lives in the SQL-Server replicator
    /// assembly. Honest bound: the package also registers <c>ISecretStore</c>/<c>ISecretsStoreMigrator</c>
    /// through factory lambdas whose service type is the Abstractions interface and whose
    /// ImplementationType is null — those two descriptors would evade this scan in isolation. The
    /// site-purity pin still holds because the same extension unconditionally registers three
    /// concrete types from the target assembly first, which this scan does catch — so the package
    /// cannot enter the container without tripping it, even though not every individual descriptor
    /// it adds is individually detectable.
    /// </summary>
    private static bool IsFromSqlServerReplicatorPackage(ServiceDescriptor descriptor) =>
        descriptor.ServiceType.Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.SqlServer"
        || descriptor.ImplementationType?.Assembly.GetName().Name
            == "ZB.MOM.WW.Secrets.Replicator.SqlServer";

    /// <summary>
    /// The gRPC hub package's one hosted service — the follower sweep. Asserted by assembly and
    /// type NAME because <c>GrpcSecretSyncService</c> is internal to the package. The hub SERVER
    /// half contributes no hosted service at all, so presence of one from this assembly is
    /// specifically the follower half and nothing else.
    /// </summary>
    private static bool IsGrpcHubSweepService(IHostedService service) =>
        service.GetType().Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.Grpc"
        && service.GetType().Name == "GrpcSecretSyncService";

    /// <summary>
    /// The complete central shape for gRPC mode, connection string included: central's store in
    /// this mode is the SHARED SQL-Server store (scadaproj#4), so a central config without a
    /// connection string is not a valid variant — it is a registration failure, pinned separately.
    /// </summary>
    private static (string Key, string Value)[] GrpcCentralConfig() =>
    [
        ("Secrets:Replication:Enabled", "true"),
        ("Secrets:Replication:Mode", "Grpc"),
        ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
        ("Secrets:SqlServer:ConnectionString", DummyConnectionString),
    ];

    private static (string Key, string Value)[] GrpcSiteConfig() =>
    [
        ("Secrets:Replication:Enabled", "true"),
        ("Secrets:Replication:Mode", "Grpc"),
        ("Secrets:GrpcHub:Endpoint", DummyHubEndpoint),
        ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
    ];

    // ---------------------------------------------------------------------------------------
    // Replication off — the default, and the shape every existing deployment is in.
    // ---------------------------------------------------------------------------------------

    [Fact]
    public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore()
    {
        using ServiceProvider provider = BuildProvider(BuildConfig());

        var store = provider.GetRequiredService<ISecretStore>();

        Assert.IsType<SqliteSecretStore>(store);
        Assert.IsNotType<ReplicatingSecretStore>(store);
    }

    [Fact]
    public void ReplicationDisabledByDefault_RegistersNoReplicatorHostedServices()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Central);

        Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService);
    }

    /// <summary>
    /// Flag off ⇒ NEITHER half of the gRPC hub, on EITHER role — even with the hub section fully
    /// populated. The mode key selects a transport; it must never be what turns replication on.
    /// </summary>
    [Theory]
    [InlineData(SecretsNodeRole.Central)]
    [InlineData(SecretsNodeRole.Site)]
    public void ReplicationDisabled_WithGrpcModeAndFullHubConfig_WiresNeitherHubHalf(
        SecretsNodeRole role)
    {
        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "false"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:Endpoint", DummyHubEndpoint),
            ("Secrets:GrpcHub:BearerToken", DummyBearerToken));

        using ServiceProvider provider = BuildProvider(config, role);

        Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
        Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
        Assert.DoesNotContain(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
        Assert.False(SecretsRegistration.UsesGrpcHub(config));
    }

    // ---------------------------------------------------------------------------------------
    // SQL-Server mode — regression pins. Behaviour must be byte-for-byte what it was before the
    // mode key existed, both when the mode is left unset and when it is named explicitly.
    // ---------------------------------------------------------------------------------------

    [Fact]
    public void ReplicationEnabledWithoutConnectionString_StaysOnPlainSqliteStore()
    {
        // The gate requires BOTH the flag and a connection string. Enabling the flag alone must not
        // reach the SQL-Server package, whose options validation would throw at registration and
        // take the whole node down at startup.
        var services = new ServiceCollection();
        services.AddLogging();

        IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true"));

        services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central);

        using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);

        Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
        Assert.DoesNotContain(services, IsSqlServerReplicatorHostedService);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("SqlServer")]
    public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore(string? mode)
    {
        IConfiguration config = mode is null
            ? BuildConfig(
                ("Secrets:Replication:Enabled", "true"),
                ("Secrets:SqlServer:ConnectionString", DummyConnectionString))
            : BuildConfig(
                ("Secrets:Replication:Enabled", "true"),
                ("Secrets:Replication:Mode", mode),
                ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        using ServiceProvider provider = BuildProvider(config);

        Assert.IsType<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
    }

    [Fact]
    public void ReplicationEnabledWithConnectionString_UndecoratedLocalStoreAlsoResolves()
    {
        // ReplicatingSecretStore is constructed from the CONCRETE SqliteSecretStore, not from
        // ISecretStore (which is the decorator itself — that would recurse). If the concrete
        // registration were ever dropped, the decorator would fail on first resolve. This is the
        // exact defect that shipped once, so it gets its own test.
        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        using ServiceProvider provider = BuildProvider(config);

        Assert.NotNull(provider.GetRequiredService<SqliteSecretStore>());
    }

    [Fact]
    public void ReplicationEnabledWithConnectionString_RegistersReplicatorHostedServices()
    {
        var services = new ServiceCollection();
        services.AddLogging();

        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central);

        // Both the hub-schema migration service and the bidirectional sweep.
        Assert.Equal(2, services.Count(IsSqlServerReplicatorHostedService));
    }

    /// <summary>
    /// SQL-Server mode is role-agnostic — both nodes of a hub topology sync bidirectionally
    /// against the same shared database. The role parameter added for the gRPC hub must not have
    /// quietly changed that.
    /// </summary>
    [Theory]
    [InlineData(SecretsNodeRole.Central)]
    [InlineData(SecretsNodeRole.Site)]
    public void SqlServerMode_IsIdenticalOnBothRoles(SecretsNodeRole role)
    {
        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "SqlServer"),
            ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        using ServiceProvider provider = BuildProvider(config, role);

        Assert.IsType<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
        Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
        Assert.DoesNotContain(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
    }

    // ---------------------------------------------------------------------------------------
    // gRPC mode — central hosts, site follows, and neither does the other's job.
    // ---------------------------------------------------------------------------------------

    [Fact]
    public void GrpcMode_Central_RegistersHubAndItsAuthInterceptor()
    {
        IConfiguration config = BuildConfig(GrpcCentralConfig());

        using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);

        // The fail-closed bearer gate must RESOLVE, not merely be listed: an interceptor that
        // cannot be activated is one gRPC would fail to attach, which is an ungated hub.
        Assert.NotNull(provider.GetService<SecretsHubAuthInterceptor>());
        Assert.True(SecretsRegistration.UsesGrpcHub(config));

        // Central's store is the SHARED SQL-Server store — one copy of every row, so both
        // central hubs serve identical manifests by construction (scadaproj#4). Undecorated:
        // pull-only means nothing publishes, so there is still nothing to wrap it in.
        Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
    }

    [Fact]
    public void GrpcMode_Central_DoesNotRegisterTheFollowerSweep()
    {
        // Central is the origin of every write. A sweep here would have it pull from itself, and
        // on a misconfigured endpoint, from somebody else.
        using ServiceProvider provider =
            BuildProvider(BuildConfig(GrpcCentralConfig()), SecretsNodeRole.Central);

        Assert.DoesNotContain(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
    }

    [Fact]
    public void GrpcMode_Site_RegistersTheFollowerSweep()
    {
        using ServiceProvider provider =
            BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);

        // Resolving the hosted services builds the sweep's whole graph — reader, keyed channel,
        // local store — which is where a missing AddZbSecrets would surface.
        Assert.Contains(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
        Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
    }

    [Fact]
    public void GrpcMode_Site_DoesNotHostTheHub()
    {
        // A site hosting the hub would serve central's secrets to anything holding the shared
        // token, from inside the site network.
        using ServiceProvider provider =
            BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);

        Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
    }

    /// <summary>
    /// A configured fallback endpoint swaps the sweep's reader for the package's failover
    /// composition and adds one keyed channel per fallback. Safe ONLY because both central nodes
    /// serve one shared SQL store (scadaproj#4) — the type-name assertion is deliberate: the
    /// failover reader is internal to the package, and its name is the observable contract here.
    /// </summary>
    [Fact]
    public void GrpcMode_Site_WithFallbackEndpoint_ResolvesTheFailoverReader()
    {
        IConfiguration config = BuildConfig(
        [
            .. GrpcSiteConfig(),
            ("Secrets:GrpcHub:FallbackEndpoints:0", DummyFallbackHubEndpoint),
        ]);

        using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site);

        object reader = provider.GetRequiredService(HubReaderInterface);
        Assert.Equal("FailoverSecretsHubReader", reader.GetType().Name);
        Assert.NotNull(provider.GetKeyedService<GrpcChannel>(FallbackChannelKey));
    }

    /// <summary>
    /// Zero fallbacks pins the unchanged default: the reader stays the plain single-endpoint
    /// client and no fallback channel enters the container — exactly the pre-0.5.0 shape every
    /// existing deployment is in.
    /// </summary>
    [Fact]
    public void GrpcMode_Site_WithoutFallbackEndpoints_KeepsThePlainHubClient()
    {
        using ServiceProvider provider =
            BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);

        object reader = provider.GetRequiredService(HubReaderInterface);
        Assert.Equal("GrpcSecretsHubClient", reader.GetType().Name);
        Assert.Null(provider.GetKeyedService<GrpcChannel>(FallbackChannelKey));
    }

    // ---------------------------------------------------------------------------------------
    // gRPC mode fails closed. There is no local-only fallback here, unlike SQL-Server mode:
    // a site quietly serving secrets that never converge is the outcome the hub exists to prevent.
    // ---------------------------------------------------------------------------------------

    [Fact]
    public void GrpcMode_Central_WithoutBearerToken_FailsAtRegistration()
    {
        var services = new ServiceCollection();
        services.AddLogging();

        // Connection string present so the missing bearer token is the ONE defect in this config —
        // the connection-string pre-check runs first and would otherwise mask it.
        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        var ex = Assert.Throws<InvalidOperationException>(
            () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));

        // The package's own message, unwrapped, naming the exact key an operator has to set.
        Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
    }

    /// <summary>
    /// Central + gRPC + no connection string is the scadaproj#4 defect asked for by name: each
    /// central node on an independent local store, one of them answering authenticated followers
    /// with an EMPTY manifest. It must be a boot failure naming the key, never a quiet fallback.
    /// </summary>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public void GrpcMode_Central_WithBlankConnectionString_FailsAtRegistration(string? connectionString)
    {
        var services = new ServiceCollection();
        services.AddLogging();

        (string, string)[] overrides = connectionString is null
            ?
            [
                ("Secrets:Replication:Enabled", "true"),
                ("Secrets:Replication:Mode", "Grpc"),
                ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
            ]
            :
            [
                ("Secrets:Replication:Enabled", "true"),
                ("Secrets:Replication:Mode", "Grpc"),
                ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
                ("Secrets:SqlServer:ConnectionString", connectionString),
            ];

        var ex = Assert.Throws<InvalidOperationException>(
            () => services.AddScadaBridgeSecrets(BuildConfig(overrides), SecretsNodeRole.Central));

        Assert.Contains(
            SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
    }

    /// <summary>
    /// The store's own connection string can never be a <c>${secret:}</c> reference — the pre-host
    /// expander needs the store this string points at in order to resolve it (bootstrap
    /// circularity, the same rule as the hub bearer token). Rejected by name rather than left to
    /// fail as a malformed connection string on first use.
    /// </summary>
    [Fact]
    public void GrpcMode_Central_WithSecretReferenceConnectionString_FailsAtRegistration()
    {
        var services = new ServiceCollection();
        services.AddLogging();

        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
            ("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}"));

        var ex = Assert.Throws<InvalidOperationException>(
            () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));

        Assert.Contains(
            SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
        Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
        // The reference NAME is config, not secret material — but the message must still not echo
        // the configured value, because a mistyped value here could be a pasted real credential.
        Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
    }

    /// <summary>
    /// The sites-don't-talk-to-SQL pin: a site node's container must contain NOTHING from the
    /// SQL-Server replicator package — not just resolve a SQLite store, but hold no descriptor
    /// that could ever open a connection to central's database.
    /// </summary>
    [Fact]
    public void GrpcMode_Site_HasNoSqlServerPackageTypesInTheContainer()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddScadaBridgeSecrets(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site);

        Assert.DoesNotContain(services, IsFromSqlServerReplicatorPackage);

        using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);

        Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
        Assert.Null(provider.GetService<SqlServerSecretStore>());
    }

    [Fact]
    public void GrpcMode_Site_WithoutEndpoint_FailsAtRegistration()
    {
        var services = new ServiceCollection();
        services.AddLogging();

        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:BearerToken", DummyBearerToken));

        var ex = Assert.Throws<InvalidOperationException>(
            () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Site));

        Assert.Contains("Secrets:GrpcHub:Endpoint", ex.Message, StringComparison.Ordinal);
    }

    [Fact]
    public void GrpcMode_Site_WithoutBearerToken_FailsAtRegistration()
    {
        var services = new ServiceCollection();
        services.AddLogging();

        IConfiguration config = BuildConfig(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:Endpoint", DummyHubEndpoint));

        var ex = Assert.Throws<InvalidOperationException>(
            () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Site));

        Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
    }

    // ---------------------------------------------------------------------------------------
    // Mode key parsing.
    // ---------------------------------------------------------------------------------------

    [Theory]
    [InlineData(null, SecretsReplicationMode.SqlServer)]
    [InlineData("", SecretsReplicationMode.SqlServer)]
    [InlineData("SqlServer", SecretsReplicationMode.SqlServer)]
    [InlineData("sqlserver", SecretsReplicationMode.SqlServer)]
    [InlineData("Grpc", SecretsReplicationMode.Grpc)]
    [InlineData("grpc", SecretsReplicationMode.Grpc)]
    [InlineData(" Grpc ", SecretsReplicationMode.Grpc)]
    public void ResolveReplicationMode_ReadsTheKey(string? value, SecretsReplicationMode expected)
    {
        IConfiguration config = value is null
            ? BuildConfig()
            : BuildConfig(("Secrets:Replication:Mode", value));

        Assert.Equal(expected, SecretsRegistration.ResolveReplicationMode(config));
    }

    [Fact]
    public void ResolveReplicationMode_RejectsAnUnknownValue_EvenWithReplicationOff()
    {
        // Caught at the boot that introduces the typo rather than at whichever later boot flips
        // Secrets:Replication:Enabled — otherwise an unrelated change fails on a key nobody touched.
        IConfiguration config = BuildConfig(("Secrets:Replication:Mode", "GrpcHub"));

        var ex = Assert.Throws<InvalidOperationException>(
            () => SecretsRegistration.ResolveReplicationMode(config));

        Assert.Contains("Secrets:Replication:Mode", ex.Message, StringComparison.Ordinal);
    }

    /// <summary>
    /// A numeric value must not be accepted. <c>Enum.TryParse</c> happily parses any integer,
    /// including ones outside the enum, so "2" would otherwise select a mode that does not exist
    /// and fall through the switch to the SQL-Server branch.
    /// </summary>
    [Fact]
    public void ResolveReplicationMode_RejectsAnOutOfRangeNumericValue()
    {
        IConfiguration config = BuildConfig(("Secrets:Replication:Mode", "7"));

        Assert.Throws<InvalidOperationException>(
            () => SecretsRegistration.ResolveReplicationMode(config));
    }
}

/// <summary>
/// Endpoint-mapping pins for the central-hosted secrets hub.
/// <para>
/// Registration and mapping are gated by one shared predicate on purpose, and this asserts the
/// consequence: the hub endpoint exists exactly when the node that serves it registered the
/// fail-closed interceptor. A mapped-but-unregistered hub would be an anonymous endpoint serving
/// every secret central holds, and it would look completely healthy.
/// </para>
/// </summary>
public class SecretsHubEndpointMappingTests
{
    private const string DummyBearerToken = "test-hub-token";

    private static WebApplication BuildCentralApp(params (string Key, string Value)[] overrides)
    {
        var builder = WebApplication.CreateBuilder();
        builder.Configuration.Sources.Clear();

        var settings = new Dictionary<string, string?>
        {
            ["Secrets:SqlitePath"] = Path.Combine(
                Path.GetTempPath(), $"sb-secrets-map-test-{Guid.NewGuid():N}.db"),
            ["Secrets:MasterKey:Source"] = "Environment",
            ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
            ["Secrets:RunMigrationsOnStartup"] = "false",
        };

        foreach ((string key, string value) in overrides)
        {
            settings[key] = value;
        }

        builder.Configuration.AddInMemoryCollection(settings);
        builder.Services.AddGrpc();
        builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);

        WebApplication app = builder.Build();
        app.MapScadaBridgeSecretsHub(app.Configuration);
        return app;
    }

    /// <summary>
    /// The gRPC service's WIRE name, from <c>package zb.mom.ww.secrets.hub.v1; service SecretsHub</c>.
    /// Matched on the route pattern rather than on the C# type, because the route is the thing a
    /// follower actually addresses and the thing a rename would break.
    /// </summary>
    private const string HubRoutePrefix = "/zb.mom.ww.secrets.hub.v1.SecretsHub/";

    private static IReadOnlyList<string> HubRoutes(WebApplication app) =>
        ((IEndpointRouteBuilder)app).DataSources
            .SelectMany(source => source.Endpoints)
            .OfType<RouteEndpoint>()
            .Select(endpoint => "/" + endpoint.RoutePattern.RawText?.TrimStart('/'))
            .Where(route => route.StartsWith(HubRoutePrefix, StringComparison.Ordinal))
            // Grpc.AspNetCore always adds a "{unimplementedMethod}" catch-all per service, which
            // answers Unimplemented. It is framework scaffolding, not an RPC the contract declares.
            .Where(route => !route.Contains('{', StringComparison.Ordinal))
            .ToList();

    private static bool HasHubEndpoint(WebApplication app) => HubRoutes(app).Count > 0;

    // Syntactically valid, deliberately unreachable — central + gRPC requires the shared
    // SQL-Server store's connection string at registration; nothing here ever connects.
    private const string DummyConnectionString =
        "Server=unused;Database=x;Integrated Security=true;";

    [Fact]
    public void GrpcMode_Central_MapsTheHubEndpoint()
    {
        using WebApplication app = BuildCentralApp(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
            ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        Assert.True(HasHubEndpoint(app));
    }

    /// <summary>
    /// Pull-only is a property of the wire contract, and this is where it becomes observable to
    /// ScadaBridge: central maps exactly the two READ methods and nothing that could accept a
    /// pushed row. If a future package version adds a write RPC, this fails here rather than
    /// silently opening a path for a site to overwrite central's secrets.
    /// </summary>
    [Fact]
    public void GrpcMode_Central_MapsOnlyTheTwoReadMethods()
    {
        using WebApplication app = BuildCentralApp(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:BearerToken", DummyBearerToken),
            ("Secrets:SqlServer:ConnectionString", DummyConnectionString));

        Assert.Equal(
            [HubRoutePrefix + "GetManifest", HubRoutePrefix + "GetSecrets"],
            HubRoutes(app).Order(StringComparer.Ordinal).ToArray());
    }

    [Fact]
    public void ReplicationDisabled_MapsNoHubEndpoint()
    {
        using WebApplication app = BuildCentralApp(
            ("Secrets:Replication:Enabled", "false"),
            ("Secrets:Replication:Mode", "Grpc"),
            ("Secrets:GrpcHub:BearerToken", DummyBearerToken));

        Assert.False(HasHubEndpoint(app));
    }

    [Fact]
    public void SqlServerMode_MapsNoHubEndpoint()
    {
        using WebApplication app = BuildCentralApp(
            ("Secrets:Replication:Enabled", "true"),
            ("Secrets:Replication:Mode", "SqlServer"),
            ("Secrets:SqlServer:ConnectionString", "Server=unused;Database=x;Integrated Security=true;"));

        Assert.False(HasHubEndpoint(app));
    }
}
