using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using ZB.MOM.WW.ScadaBridge.Host.Services;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;

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

/// <summary>
/// Removes AkkaHostedService from running as a hosted service while keeping it
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
/// </summary>
internal static class AkkaHostedServiceRemover
{
    internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
    {
        // Pattern used in Program.cs:
        //   services.AddSingleton<AkkaHostedService>();             // index N
        //   services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());  // index N+1
        // We keep the singleton so DI resolution works, but remove the IHostedService
        // factory so StartAsync is never called.
        int akkaIndex = -1;
        for (int i = 0; i < services.Count; i++)
        {
            if (services[i].ServiceType == typeof(AkkaHostedService))
            {
                akkaIndex = i;
                break;
            }
        }

        if (akkaIndex < 0) return;

        // The IHostedService factory is the next registration after the singleton
        for (int i = akkaIndex + 1; i < services.Count; i++)
        {
            if (services[i].ServiceType == typeof(IHostedService)
                && services[i].ImplementationFactory != null)
            {
                services.RemoveAt(i);
                break;
            }
        }
    }
}

/// <summary>
/// Verifies every expected DI service resolves from the Central composition root.
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
/// </summary>
[Collection(HostBootCollection.Name)]
public class CentralCompositionRootTests : IDisposable
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly string? _previousEnv;
    private readonly CentralDbTestEnvironment _dbEnv;

    public CentralCompositionRootTests()
    {
        _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
        Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");

        // Supply the pepper, MachineDataDb and the G-4 secret-backed keys
        // (ConfigurationDb / LDAP service-account password / JWT signing key) as
        // whole-key env overrides so the pre-host config builder — which calls
        // AddEnvironmentVariables() before the ${secret:...} expander runs — sees
        // concrete values and the Central-role StartupValidator preflight passes.
        _dbEnv = new CentralDbTestEnvironment();

        _factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureAppConfiguration((_, config) =>
                {
                    config.AddInMemoryCollection(new Dictionary<string, string?>
                    {
                        ["ScadaBridge:Node:NodeName"] = "central-a",
                        ["ScadaBridge:Node:NodeHostname"] = "localhost",
                        ["ScadaBridge:Node:RemotingPort"] = "0",
                        ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
                        ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
                        ["ScadaBridge:Database:SkipMigrations"] = "true",
                        ["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
                        // Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
                        // ServiceAccountDn is now required by the library's LdapOptionsValidator
                        // (ValidateOnStart), so it must be present for the host to start.
                        ["ScadaBridge:Security:Ldap:Server"] = "localhost",
                        ["ScadaBridge:Security:Ldap:Port"] = "3893",
                        ["ScadaBridge:Security:Ldap:Transport"] = "None",
                        ["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
                        ["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
                        ["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
                        // Auth re-arch (C5): inbound-API keys live in the shared
                        // ZB.MOM.WW.Auth.ApiKeys SQLite store. The verifier reuses
                        // this same config key as its pepper secret (PepperSecretName),
                        // and AddZbApiKeyAuth fails fast if it is missing/weak — so a
                        // configured pepper is still required for the host to start.
                        ["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
                    });
                });
                builder.UseSetting("ScadaBridge:Node:Role", "Central");
                builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
                builder.ConfigureServices(services =>
                {
                    // Replace SQL Server with in-memory database
                    var descriptorsToRemove = services
                        .Where(d =>
                            d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
                            d.ServiceType == typeof(DbContextOptions) ||
                            d.ServiceType == typeof(ScadaBridgeDbContext) ||
                            d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
                        .ToList();
                    foreach (var d in descriptorsToRemove)
                        services.Remove(d);

                    services.AddDbContext<ScadaBridgeDbContext>(options =>
                        options.UseInMemoryDatabase($"CompositionRootTests_{Guid.NewGuid()}"));

                    // Keep AkkaHostedService in DI (other services depend on it)
                    // but prevent it from starting by removing only its IHostedService registration.
                    AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
                });
            });

        // Trigger host build
        _ = _factory.Server;
    }

    public void Dispose()
    {
        // try/finally: a throwing host teardown must not strand the process-wide
        // environment variables for the rest of the run.
        try
        {
            _factory.Dispose();
        }
        finally
        {
            Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
            _dbEnv.Dispose();
        }
    }

    // --- Singletons ---

    [Theory]
    [MemberData(nameof(CentralSingletonServices))]
    public void Central_ResolveSingleton(Type serviceType)
    {
        var service = _factory.Services.GetService(serviceType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> CentralSingletonServices => new[]
    {
        new object[] { typeof(CommunicationService) },
        new object[] { typeof(ISiteHealthCollector) },
        new object[] { typeof(CentralHealthAggregator) },
        new object[] { typeof(ICentralHealthAggregator) },
        new object[] { typeof(OperationLockManager) },
        new object[] { typeof(OAuth2TokenService) },
        new object[] { typeof(InboundScriptExecutor) },
        // Security: the shared ZB.MOM.WW.Auth.Ldap service is registered as a stateless
        // singleton by AddZbLdapAuth (Task 1.2), replacing the old scoped LdapAuthService.
        new object[] { typeof(ILdapAuthService) },
    };

    // --- Scoped services ---

    [Theory]
    [MemberData(nameof(CentralScopedServices))]
    public void Central_ResolveScoped(Type serviceType)
    {
        using var scope = _factory.Services.CreateScope();
        var service = scope.ServiceProvider.GetService(serviceType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> CentralScopedServices => new[]
    {
        // TemplateEngine
        new object[] { typeof(TemplateService) },
        new object[] { typeof(SharedScriptService) },
        new object[] { typeof(InstanceService) },
        new object[] { typeof(SiteService) },
        new object[] { typeof(AreaService) },
        new object[] { typeof(TemplateDeletionService) },
        // DeploymentManager
        new object[] { typeof(IFlatteningPipeline) },
        new object[] { typeof(DeploymentService) },
        new object[] { typeof(ArtifactDeploymentService) },
        // Security (ILdapAuthService is now a singleton — see CentralSingletonServices)
        new object[] { typeof(JwtTokenService) },
        new object[] { typeof(RoleMapper) },
        // InboundAPI — auth re-arch (C5): the legacy ApiKeyValidator was retired;
        // inbound auth runs through the shared ZB.MOM.WW.Auth.ApiKeys verifier.
        new object[] { typeof(RouteHelper) },
        // ExternalSystemGateway
        new object[] { typeof(ExternalSystemClient) },
        new object[] { typeof(IExternalSystemClient) },
        new object[] { typeof(DatabaseGateway) },
        new object[] { typeof(IDatabaseGateway) },
        // NotificationService — central-only SMTP primitives. The orphan
        // NotificationDeliveryService + INotificationDeliveryService were removed
        // (NS-019) when sites stopped delivering notifications; the central
        // EmailNotificationDeliveryAdapter is now the only resolver of these
        // primitives.
        new object[] { typeof(Func<ISmtpClientWrapper>) },
        new object[] { typeof(OAuth2TokenService) },
        // ConfigurationDatabase repositories
        new object[] { typeof(ScadaBridgeDbContext) },
        new object[] { typeof(ISecurityRepository) },
        new object[] { typeof(ICentralUiRepository) },
        new object[] { typeof(ITemplateEngineRepository) },
        new object[] { typeof(IDeploymentManagerRepository) },
        new object[] { typeof(ISiteRepository) },
        new object[] { typeof(IExternalSystemRepository) },
        new object[] { typeof(INotificationRepository) },
        new object[] { typeof(IInboundApiRepository) },
        new object[] { typeof(IAuditService) },
        new object[] { typeof(IInstanceLocator) },
        // CentralUI
        new object[] { typeof(AuthenticationStateProvider) },
    };

    // --- Transient services ---

    [Theory]
    [MemberData(nameof(CentralTransientServices))]
    public void Central_ResolveTransient(Type serviceType)
    {
        using var scope = _factory.Services.CreateScope();
        var service = scope.ServiceProvider.GetService(serviceType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> CentralTransientServices => new[]
    {
        new object[] { typeof(FlatteningService) },
        new object[] { typeof(DiffService) },
        new object[] { typeof(RevisionHashService) },
        new object[] { typeof(ScriptCompiler) },
        new object[] { typeof(SemanticValidator) },
        new object[] { typeof(ValidationService) },
    };

    // --- Options ---

    [Theory]
    [MemberData(nameof(CentralOptions))]
    public void Central_ResolveOptions(Type optionsType)
    {
        var service = _factory.Services.GetService(optionsType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> CentralOptions => new[]
    {
        new object[] { typeof(IOptions<NodeOptions>) },
        new object[] { typeof(IOptions<ClusterOptions>) },
        new object[] { typeof(IOptions<DatabaseOptions>) },
        new object[] { typeof(IOptions<CommunicationOptions>) },
        new object[] { typeof(IOptions<HealthMonitoringOptions>) },
        new object[] { typeof(IOptions<NotificationOptions>) },
        new object[] { typeof(IOptions<LoggingOptions>) },
        new object[] { typeof(IOptions<SecurityOptions>) },
        new object[] { typeof(IOptions<InboundApiOptions>) },
        new object[] { typeof(IOptions<ManagementServiceOptions>) },
        new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
    };

    // --- Hosted services ---

    [Fact]
    public void Central_CentralHealthAggregator_RegisteredAsHostedService()
    {
        var hostedServices = _factory.Services.GetServices<IHostedService>();
        Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
    }

    // --- InboundAPI-022 regression ---

    /// <summary>
    /// InboundAPI-022 regression: the Central composition root MUST register a
    /// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
    /// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
    /// standby central node continues to serve the inbound API, racing the
    /// active node and executing scripts against stale singleton state. The
    /// design's "central cluster only (active node)" guarantee is enforced only
    /// when the production gate is wired here.
    ///
    /// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
    /// — a registration the framework cannot resolve to a concrete instance is
    /// indistinguishable from "missing" at runtime, which is the failure mode
    /// the finding describes.
    /// </summary>
    [Fact]
    public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
    {
        var gate = _factory.Services.GetService<IActiveNodeGate>();
        Assert.NotNull(gate);
        Assert.IsType<ActiveNodeGate>(gate);
    }

    // --- ScadaBridge#22 regression ---

    /// <summary>
    /// ScadaBridge#22 regression: the Secrets.Ui components mounted at /admin/secrets inject
    /// <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> — the SHARED-lib seam, not the repo's own
    /// <see cref="IAuditWriter"/> (deliberately distinct per its doc comment). The secrets
    /// adoption mounted the UI without bridging that seam, so component activation threw and
    /// the page 500'd on every render; the login wall hid it. This resolves the full
    /// injection set of every Secrets.Ui component (SecretsList / SecretEditor /
    /// RevealButton / ConfirmDeleteModal) out of the REAL central composition root, because
    /// the gap is invisible until the graph is actually built — the same defect class as the
    /// four caught in the secrets library itself.
    /// </summary>
    [Theory]
    [InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretStore))]
    [InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretCipher))]
    [InlineData(typeof(ZB.MOM.WW.Audit.IAuditWriter))]
    public void Central_Resolves_SecretsUiComponentDependencies(Type serviceType)
    {
        var service = _factory.Services.GetService(serviceType);
        Assert.NotNull(service);
    }

    /// <summary>
    /// The bridge must route secrets-UI audit events into the CENTRAL direct-write path
    /// (<see cref="ICentralAuditWriter"/> → dbo.AuditLog), not the site SQLite chain — the
    /// site writer chain is registered on central but by design never resolved there, and
    /// events written to it would dead-end in a local file no forwarder ever drains.
    /// </summary>
    [Fact]
    public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
    {
        var writer = _factory.Services.GetService<ZB.MOM.WW.Audit.IAuditWriter>();
        Assert.NotNull(writer);
        Assert.IsType<CentralSharedSeamAuditWriter>(writer);
    }
}

/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
/// </summary>
public class SiteCompositionRootTests : IDisposable
{
    private readonly WebApplication _host;
    private readonly string _tempDbPath;
    private readonly string _tempTrackingDbPath;
    private readonly string _tempLocalDbPath;

    public SiteCompositionRootTests()
    {
        _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
        _tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.db");
        _tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{Guid.NewGuid()}.db");

        var builder = WebApplication.CreateBuilder();
        builder.Configuration.Sources.Clear();
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["ScadaBridge:Node:Role"] = "Site",
            ["ScadaBridge:Node:NodeName"] = "node-a",
            ["ScadaBridge:Node:NodeHostname"] = "test-site",
            ["ScadaBridge:Node:SiteId"] = "TestSite",
            ["ScadaBridge:Node:RemotingPort"] = "0",
            ["ScadaBridge:Node:GrpcPort"] = "0",
            ["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
            // Retained for the site config DB; it no longer points the tracking store
            // anywhere — that store now opens the consolidated database below.
            ["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
            // The consolidated site database. IOperationTrackingStore resolves ILocalDb
            // and creates the file eagerly in its ctor, so this must be a temp path or
            // the composition-root test litters the working directory.
            ["LocalDb:Path"] = _tempLocalDbPath,
            // ClusterOptions requires at least one seed node (ClusterOptionsValidator).
            ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
            ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
        });

        // gRPC server registration (mirrors Program.cs site section)
        builder.Services.AddGrpc();
        builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();

        SiteServiceRegistration.Configure(builder.Services, builder.Configuration);

        // Keep AkkaHostedService in DI (other services depend on it)
        // but prevent it from starting by removing only its IHostedService registration.
        AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);

        _host = builder.Build();
    }

    public void Dispose()
    {
        (_host as IDisposable)?.Dispose();
        try { File.Delete(_tempDbPath); } catch { /* best effort */ }
        try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ }
        try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
    }

    [Fact]
    public void Site_Resolves_IOperationTrackingStore()
    {
        // Site Call Audit's site-local source of truth. AkkaHostedService and
        // ScriptRuntimeContext resolve this with GetService and silently degrade
        // when absent (audit-only telemetry, no cached-drain, no PullSiteCalls) —
        // so its presence on the SITE composition root must be locked here
        // (arch-review 08 round 2 NF4/T8: AddSiteRuntime never registered it,
        // despite two comments claiming it did).
        var store = _host.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>();
        Assert.NotNull(store);
    }

    // --- Singletons ---

    [Theory]
    [MemberData(nameof(SiteSingletonServices))]
    public void Site_ResolveSingleton(Type serviceType)
    {
        var service = _host.Services.GetService(serviceType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> SiteSingletonServices => new[]
    {
        new object[] { typeof(CommunicationService) },
        new object[] { typeof(ISiteHealthCollector) },
        new object[] { typeof(SiteStorageService) },
        new object[] { typeof(ScriptCompilationService) },
        new object[] { typeof(SharedScriptLibrary) },
        new object[] { typeof(SiteStreamManager) },
        new object[] { typeof(ISiteStreamSubscriber) },
        new object[] { typeof(SiteStreamGrpcServer) },
        new object[] { typeof(IDataConnectionFactory) },
        new object[] { typeof(StoreAndForwardStorage) },
        new object[] { typeof(StoreAndForwardService) },
        new object[] { typeof(ISiteEventLogger) },
        new object[] { typeof(IEventLogQueryService) },
        new object[] { typeof(ISiteIdentityProvider) },
        new object[] { typeof(IHealthReportTransport) },
        // M2.15 (#29): the active-node purge gate must be registered on site nodes
        // so EventLogPurge only runs on the active node.
        new object[] { typeof(SiteEventLogActiveNodeCheck) },
    };

    // --- Scoped services ---

    [Theory]
    [MemberData(nameof(SiteScopedServices))]
    public void Site_ResolveScoped(Type serviceType)
    {
        using var scope = _host.Services.CreateScope();
        var service = scope.ServiceProvider.GetService(serviceType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> SiteScopedServices => new[]
    {
        new object[] { typeof(IExternalSystemRepository) },
        // INotificationRepository is intentionally absent — notification config is
        // central-only (arch-review 08 §1.3/#23); the site-side variant was removed.
        new object[] { typeof(ExternalSystemClient) },
        new object[] { typeof(IExternalSystemClient) },
        new object[] { typeof(DatabaseGateway) },
        new object[] { typeof(IDatabaseGateway) },
    };

    // --- Implementation type assertions ---

    [Fact]
    public void Site_ExternalSystemRepository_IsSiteImplementation()
    {
        using var scope = _host.Services.CreateScope();
        var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
        Assert.IsType<SiteExternalSystemRepository>(repo);
    }

    [Fact]
    public void Site_NotificationRepository_IsNotRegistered()
    {
        // Notification delivery is central-only (CLAUDE.md "Notification delivery is
        // central-only"); the site-local notification_lists/smtp_configurations tables
        // are purged on every artifact apply, so a site-side INotificationRepository
        // could only ever serve empty results. It must not be registered at all.
        using var scope = _host.Services.CreateScope();
        var repo = scope.ServiceProvider.GetService<INotificationRepository>();
        Assert.Null(repo);
    }

    // --- Options ---

    [Theory]
    [MemberData(nameof(SiteOptions))]
    public void Site_ResolveOptions(Type optionsType)
    {
        var service = _host.Services.GetService(optionsType);
        Assert.NotNull(service);
    }

    public static IEnumerable<object[]> SiteOptions => new[]
    {
        new object[] { typeof(IOptions<NodeOptions>) },
        new object[] { typeof(IOptions<ClusterOptions>) },
        new object[] { typeof(IOptions<DatabaseOptions>) },
        new object[] { typeof(IOptions<CommunicationOptions>) },
        new object[] { typeof(IOptions<HealthMonitoringOptions>) },
        new object[] { typeof(IOptions<NotificationOptions>) },
        new object[] { typeof(IOptions<LoggingOptions>) },
        new object[] { typeof(IOptions<SiteRuntimeOptions>) },
        new object[] { typeof(IOptions<DataConnectionOptions>) },
        new object[] { typeof(IOptions<StoreAndForwardOptions>) },
        new object[] { typeof(IOptions<SiteEventLogOptions>) },
    };

    // --- Hosted services ---

    [Theory]
    [MemberData(nameof(SiteHostedServices))]
    public void Site_HostedServiceRegistered(Type expectedType)
    {
        var hostedServices = _host.Services.GetServices<IHostedService>();
        Assert.Contains(hostedServices, s => s.GetType() == expectedType);
    }

    public static IEnumerable<object[]> SiteHostedServices => new[]
    {
        new object[] { typeof(HealthReportSender) },
        new object[] { typeof(SiteStorageInitializer) },
        new object[] { typeof(EventLogPurgeService) },
    };
}
