using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;

namespace ZB.MOM.WW.MxGateway.Tests.Configuration;

public sealed class GatewayOptionsValidatorTests
{
    // A non-blank LDAP bind password for tests. The shipped design default is now blank
    // (string.Empty) so the secret-sourced value fails closed if unresolved; enabled-LDAP test
    // options must therefore supply an explicit password to pass the required-field check.
    private const string TestBindPassword = "test-bind-password";

    // Constructs the minimal valid GatewayOptions by relying on each sub-option's
    // design-default values; those defaults are validated separately in GatewayOptionsTests.
    // The one exception is the LDAP bind password (blank by design default), which we supply.
    private static GatewayOptions ValidOptions() => new() { Ldap = ValidLdapOptions() };

    // Enabled LDAP options with all class defaults plus the non-blank bind password.
    private static LdapOptions ValidLdapOptions() => new() { ServiceAccountPassword = TestBindPassword };

    // Returns enabled LDAP options that pass all checks except Port.
    // The class defaults already satisfy the remaining blank-field checks; we override Enabled
    // (must be true to exercise the port check), Port, and the now-blank-by-default bind password.
    private static LdapOptions LdapOptionsWithPort(int port) => new()
    {
        Enabled = true,
        Port = port,
        ServiceAccountPassword = TestBindPassword,
    };

    private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
        => new()
        {
            Authentication = source.Authentication,
            Ldap = ldap,
            Worker = source.Worker,
            Sessions = source.Sessions,
            Events = source.Events,
            Dashboard = source.Dashboard,
            Protocol = source.Protocol,
            Alarms = source.Alarms,
            Tls = source.Tls,
        };

    private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
        => new()
        {
            Authentication = source.Authentication,
            Ldap = source.Ldap,
            Worker = source.Worker,
            Sessions = source.Sessions,
            Events = source.Events,
            Dashboard = source.Dashboard,
            Protocol = source.Protocol,
            Alarms = source.Alarms,
            Tls = tls,
        };

    /// <summary>Verifies default TLS options pass validation.</summary>
    [Fact]
    public void Validate_Succeeds_WithDefaultTlsOptions()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a zero <see cref="TlsOptions.ValidityYears"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenTlsValidityYearsOutOfRange()
    {
        GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 0 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
    }

    /// <summary>Verifies a <see cref="TlsOptions.ValidityYears"/> above the allowed maximum fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenTlsValidityYearsTooLarge()
    {
        GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 101 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
    }

    /// <summary>Verifies a blank entry in <see cref="TlsOptions.AdditionalDnsNames"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenAdditionalDnsNameBlank()
    {
        GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { AdditionalDnsNames = [" "] });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames"));
    }

    /// <summary>Verifies a blank <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenSelfSignedCertPathBlank()
    {
        GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { SelfSignedCertPath = "  " });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
    }

    /// <summary>Verifies an LDAP port of zero fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenLdapPortIsZero()
    {
        GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(0));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 0)"));
    }

    /// <summary>Verifies an LDAP port above the allowed maximum fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenLdapPortExceedsMaximum()
    {
        GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(70000));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 70000)"));
    }

    /// <summary>Verifies enabled LDAP with a valid port passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenLdapEnabledWithValidPort()
    {
        GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(389));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    // -------------------------------------------------------------------------
    // AlarmFallbackOptions validation
    // -------------------------------------------------------------------------

    private static AlarmsOptions EnabledAlarmsWithFallback(AlarmFallbackOptions fallback) => new()
    {
        Enabled = true,
        DefaultArea = "Galaxy",
        Fallback = fallback,
    };

    private static GatewayOptions CloneWithAlarms(GatewayOptions source, AlarmsOptions alarms)
        => new()
        {
            Authentication = source.Authentication,
            Ldap = source.Ldap,
            Worker = source.Worker,
            Sessions = source.Sessions,
            Events = source.Events,
            Dashboard = source.Dashboard,
            Protocol = source.Protocol,
            Alarms = alarms,
            Tls = source.Tls,
        };

    /// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
    [Fact]
    public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
    {
        AlarmsOptions alarms = new();
        Assert.Equal(500, alarms.PollIntervalMilliseconds);
        Assert.Equal(1024, alarms.MaxAlarmsPerFetch);

        ValidateOptionsResult result = new GatewayOptionsValidator()
            .Validate(null, CloneWithAlarms(ValidOptions(), alarms));
        Assert.True(result.Succeeded);
    }

    /// <summary>
    ///     A poll cadence outside the 100 ms – 1 h range must fail validation.
    ///     Both values are stamped onto every worker launch environment, so
    ///     they are validated whether or not the central alarm monitor is
    ///     enabled.
    /// </summary>
    /// <param name="pollIntervalMilliseconds">Cadence under test.</param>
    /// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
    [Theory]
    [InlineData(99, false)]
    [InlineData(0, false)]
    [InlineData(-1, false)]
    [InlineData(99, true)]
    // Above the one-hour ceiling the cadence stops being a cadence: int.MaxValue
    // milliseconds is ~24 days, which silently disables alarm polling.
    [InlineData(3_600_001, false)]
    [InlineData(int.MaxValue, false)]
    [InlineData(int.MaxValue, true)]
    public void Validate_Fails_WhenAlarmPollIntervalOutOfRange(
        int pollIntervalMilliseconds,
        bool alarmsEnabled)
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            new AlarmsOptions
            {
                Enabled = alarmsEnabled,
                DefaultArea = "Galaxy",
                PollIntervalMilliseconds = pollIntervalMilliseconds,
            });

        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);

        Assert.False(result.Succeeded);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Alarms:PollIntervalMilliseconds", StringComparison.Ordinal));
    }

    /// <summary>
    ///     A per-fetch cap outside the 64 – 65,536 range must fail validation.
    ///     The cap doubles as the truncation-detection threshold in the worker,
    ///     so a tiny cap would make almost every fetch read as truncated; and
    ///     the worker is a 32-bit process that materializes each reply as one
    ///     BSTR plus a full XmlDocument, so an unbounded cap is an
    ///     out-of-memory fault on the STA rather than a slow poll.
    /// </summary>
    /// <param name="maxAlarmsPerFetch">Cap under test.</param>
    /// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
    [Theory]
    [InlineData(63, false)]
    [InlineData(0, false)]
    [InlineData(-1, false)]
    [InlineData(63, true)]
    [InlineData(65_537, false)]
    [InlineData(int.MaxValue, false)]
    [InlineData(int.MaxValue, true)]
    public void Validate_Fails_WhenMaxAlarmsPerFetchOutOfRange(
        int maxAlarmsPerFetch,
        bool alarmsEnabled)
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            new AlarmsOptions
            {
                Enabled = alarmsEnabled,
                DefaultArea = "Galaxy",
                MaxAlarmsPerFetch = maxAlarmsPerFetch,
            });

        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);

        Assert.False(result.Succeeded);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Alarms:MaxAlarmsPerFetch", StringComparison.Ordinal));
    }

    /// <summary>Verifies the boundary values themselves are accepted at both ends.</summary>
    /// <param name="pollIntervalMilliseconds">Cadence under test.</param>
    /// <param name="maxAlarmsPerFetch">Cap under test.</param>
    [Theory]
    [InlineData(100, 64)]              // floors
    [InlineData(3_600_000, 65_536)]    // ceilings
    public void Validate_Succeeds_AtAlarmPollCadenceAndFetchCapBoundaries(
        int pollIntervalMilliseconds,
        int maxAlarmsPerFetch)
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            new AlarmsOptions
            {
                Enabled = true,
                DefaultArea = "Galaxy",
                PollIntervalMilliseconds = pollIntervalMilliseconds,
                MaxAlarmsPerFetch = maxAlarmsPerFetch,
            });

        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
    [Fact]
    public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
    {
        // Even an invalid Mode is acceptable when Enabled = false.
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            new AlarmsOptions
            {
                Enabled = false,
                Fallback = new AlarmFallbackOptions { Mode = "InvalidMode" },
            });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies the default "Auto" fallback mode passes validation when alarms are enabled.</summary>
    [Fact]
    public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig()
    {
        // Default AlarmFallbackOptions (Mode="Auto") must pass validation when alarms are enabled.
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions()));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies each recognised (case-insensitive) fallback mode passes validation.</summary>
    /// <param name="mode">Fallback mode value under test.</param>
    [Theory]
    [InlineData("Auto")]
    [InlineData("ForceAlarmManager")]
    [InlineData("ForceSubtag")]
    [InlineData("auto")]
    [InlineData("FORCESUBTAG")]
    public void Validate_Succeeds_WhenAlarmsEnabled_RecognisedMode(string mode)
    {
        AlarmsOptions alarms = EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode });
        GatewayOptions options = CloneWithAlarms(ValidOptions(), alarms);
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies an unrecognised fallback mode fails validation when alarms are enabled.</summary>
    [Fact]
    public void Validate_Fails_WhenAlarmsEnabled_InvalidMode()
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "InvalidMode" }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode"));
    }

    /// <summary>Verifies "ForceSubtag" fails validation without a Galaxy repository and without include attributes.</summary>
    [Fact]
    public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes()
    {
        // ForceSubtag without galaxy repository and without IncludeAttributes must fail.
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions
            {
                Mode = "ForceSubtag",
                Discovery = new AlarmDiscoveryOptions
                {
                    UseGalaxyRepository = false,
                    IncludeAttributes = [],
                },
            }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("ForceSubtag") && f.Contains("Discovery"));
    }

    /// <summary>Verifies "ForceSubtag" passes validation without a Galaxy repository when include attributes are supplied.</summary>
    [Fact]
    public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes()
    {
        // ForceSubtag without galaxy repository is allowed when IncludeAttributes is non-empty.
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions
            {
                Mode = "ForceSubtag",
                Discovery = new AlarmDiscoveryOptions
                {
                    UseGalaxyRepository = false,
                    IncludeAttributes = ["attr1"],
                },
            }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies "ForceSubtag" passes validation when a Galaxy repository is used, even without include attributes.</summary>
    [Fact]
    public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository()
    {
        // ForceSubtag + UseGalaxyRepository=true (default) must pass even without IncludeAttributes.
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions
            {
                Mode = "ForceSubtag",
                Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = true },
            }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.ConsecutiveFailureThreshold"/> fails validation.</summary>
    /// <param name="value">Threshold value under test.</param>
    /// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
    [Theory]
    [InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
    [InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
    public void Validate_Fails_WhenConsecutiveFailureThresholdBelowOne(int value, string keyPart)
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions { ConsecutiveFailureThreshold = value }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains(keyPart));
    }

    /// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.FailbackProbeIntervalSeconds"/> fails validation.</summary>
    /// <param name="value">Interval value under test.</param>
    /// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
    [Theory]
    [InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
    [InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
    public void Validate_Fails_WhenFailbackProbeIntervalSecondsBelowOne(int value, string keyPart)
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackProbeIntervalSeconds = value }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains(keyPart));
    }

    /// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.FailbackStableProbes"/> fails validation.</summary>
    /// <param name="value">Probe count value under test.</param>
    /// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
    [Theory]
    [InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))]
    [InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))]
    public void Validate_Fails_WhenFailbackStableProbesBelowOne(int value, string keyPart)
    {
        GatewayOptions options = CloneWithAlarms(
            ValidOptions(),
            EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackStableProbes = value }));
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains(keyPart));
    }

    // -------------------------------------------------------------------------
    // AllowMultipleEventSubscribers / MaxEventSubscribersPerSession validation
    // -------------------------------------------------------------------------

    private static GatewayOptions CloneWithSessions(GatewayOptions source, SessionOptions sessions)
        => new()
        {
            Authentication = source.Authentication,
            Ldap = source.Ldap,
            Worker = source.Worker,
            Sessions = sessions,
            Events = source.Events,
            Dashboard = source.Dashboard,
            Protocol = source.Protocol,
            Alarms = source.Alarms,
            Tls = source.Tls,
        };

    /// <summary>Verifies <see cref="SessionOptions.AllowMultipleEventSubscribers"/> set to <see langword="true"/> passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenAllowMultipleEventSubscribersIsTrue()
    {
        // AllowMultipleEventSubscribers=true must now validate cleanly (no longer rejected).
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { AllowMultipleEventSubscribers = true });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a non-positive <see cref="SessionOptions.MaxEventSubscribersPerSession"/> fails validation.</summary>
    /// <param name="value">Subscriber cap value under test.</param>
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Validate_Fails_WhenMaxEventSubscribersPerSessionBelowOne(int value)
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { MaxEventSubscribersPerSession = value });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Sessions:MaxEventSubscribersPerSession"));
    }

    /// <summary>Verifies a positive <see cref="SessionOptions.MaxEventSubscribersPerSession"/> passes validation.</summary>
    /// <param name="value">Subscriber cap value under test.</param>
    [Theory]
    [InlineData(1)]
    [InlineData(8)]
    [InlineData(32)]
    public void Validate_Succeeds_WhenMaxEventSubscribersPerSessionIsPositive(int value)
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { MaxEventSubscribersPerSession = value });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies default <see cref="SessionOptions"/> pass validation.</summary>
    [Fact]
    public void Validate_Succeeds_WithDefaultSessionOptions()
    {
        // Default SessionOptions (AllowMultipleEventSubscribers=false, MaxEventSubscribersPerSession=8)
        // must validate cleanly.
        GatewayOptions options = CloneWithSessions(ValidOptions(), new SessionOptions());
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    // -------------------------------------------------------------------------
    // WorkerReadyWaitTimeoutMs validation
    // -------------------------------------------------------------------------

    /// <summary>Verifies a negative <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenWorkerReadyWaitTimeoutMsIsNegative()
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { WorkerReadyWaitTimeoutMs = -1 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Sessions:WorkerReadyWaitTimeoutMs"));
    }

    /// <summary>Verifies a zero <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsZero()
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { WorkerReadyWaitTimeoutMs = 0 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a positive <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsPositive()
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { WorkerReadyWaitTimeoutMs = 5000 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a negative <see cref="SessionOptions.DetachGraceSeconds"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenDetachGraceSecondsIsNegative()
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { DetachGraceSeconds = -1 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Sessions:DetachGraceSeconds"));
    }

    /// <summary>Verifies a zero <see cref="SessionOptions.DetachGraceSeconds"/> passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenDetachGraceSecondsIsZero()
    {
        GatewayOptions options = CloneWithSessions(
            ValidOptions(),
            new SessionOptions { DetachGraceSeconds = 0 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    // -------------------------------------------------------------------------
    // ReplayBufferCapacity / ReplayRetentionSeconds validation
    // -------------------------------------------------------------------------

    private static GatewayOptions CloneWithEvents(GatewayOptions source, EventOptions events)
        => new()
        {
            Authentication = source.Authentication,
            Ldap = source.Ldap,
            Worker = source.Worker,
            Sessions = source.Sessions,
            Events = events,
            Dashboard = source.Dashboard,
            Protocol = source.Protocol,
            Alarms = source.Alarms,
            Tls = source.Tls,
        };

    /// <summary>Verifies a negative <see cref="EventOptions.ReplayBufferCapacity"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenReplayBufferCapacityIsNegative()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { ReplayBufferCapacity = -1 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Events:ReplayBufferCapacity"));
    }

    /// <summary>Verifies a zero <see cref="EventOptions.ReplayBufferCapacity"/> passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenReplayBufferCapacityIsZero()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { ReplayBufferCapacity = 0 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a negative <see cref="EventOptions.ReplayRetentionSeconds"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenReplayRetentionSecondsIsNegative()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { ReplayRetentionSeconds = -1 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Events:ReplayRetentionSeconds"));
    }

    /// <summary>Verifies a zero <see cref="EventOptions.ReplayRetentionSeconds"/> passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenReplayRetentionSecondsIsZero()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { ReplayRetentionSeconds = 0 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a <see cref="EventOptions.MaxSparseArrayLength"/> below one fails validation.</summary>
    /// <param name="value">Sparse-array cap under test.</param>
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Validate_Fails_WhenMaxSparseArrayLengthBelowOne(int value)
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { MaxSparseArrayLength = value });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Events:MaxSparseArrayLength"));
    }

    /// <summary>Verifies a positive <see cref="EventOptions.MaxSparseArrayLength"/> within range passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenMaxSparseArrayLengthWithinRange()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { MaxSparseArrayLength = 1 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    private static GatewayOptions CloneWithAuthentication(GatewayOptions source, AuthenticationOptions authentication)
        => new()
        {
            Authentication = authentication,
            Ldap = source.Ldap,
            Worker = source.Worker,
            Sessions = source.Sessions,
            Events = source.Events,
            Dashboard = source.Dashboard,
            Protocol = source.Protocol,
            Alarms = source.Alarms,
            Tls = source.Tls,
        };

    /// <summary>Verifies the default (CommonApplicationData-derived) auth DB path is rooted and passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WithDefaultRootedSqlitePath()
    {
        // The default AuthenticationOptions.SqlitePath is derived from CommonApplicationData,
        // which is rooted on every host OS.
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a non-rooted <see cref="AuthenticationOptions.SqlitePath"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenSqlitePathNotRooted()
    {
        GatewayOptions options = CloneWithAuthentication(
            ValidOptions(),
            new AuthenticationOptions { SqlitePath = "gateway-auth.db" });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
    }

    /// <summary>
    /// Verifies an absolute auth DB path <em>inside</em> the application directory fails. This is
    /// the gap the rooted check does not close: the path that lost every API key on a production
    /// host on 2026-08-09 was absolute and passed rooting cleanly — it simply lived in the directory
    /// the upgrade procedure renames away.
    /// </summary>
    [Fact]
    public void Validate_Fails_WhenSqlitePathIsUnderContentRoot()
    {
        string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}");
        GatewayOptions options = CloneWithAuthentication(
            ValidOptions(),
            new AuthenticationOptions { SqlitePath = Path.Combine(contentRoot, "gateway-auth.db") });

        ValidateOptionsResult result =
            new GatewayOptionsValidator(contentRootPath: contentRoot).Validate(null, options);

        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Authentication:SqlitePath")
                && f.Contains("must not be inside the application directory"));
    }

    /// <summary>
    /// Verifies the content-root rule is not a bare string prefix test: a sibling directory whose
    /// name merely begins with the content root's must still pass.
    /// </summary>
    [Fact]
    public void Validate_Succeeds_WhenSqlitePathIsSiblingOfContentRoot()
    {
        string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}");
        GatewayOptions options = CloneWithAuthentication(
            ValidOptions(),
            new AuthenticationOptions { SqlitePath = contentRoot + "-data" + Path.DirectorySeparatorChar + "gateway-auth.db" });

        ValidateOptionsResult result =
            new GatewayOptionsValidator(contentRootPath: contentRoot).Validate(null, options);

        Assert.True(result.Succeeded);
    }

    /// <summary>
    /// Verifies a store path outside the application directory passes — the rule must reject only
    /// the genuinely unsafe location, not every absolute path.
    /// </summary>
    [Fact]
    public void Validate_Succeeds_WhenSqlitePathIsOutsideContentRoot()
    {
        string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}");
        GatewayOptions options = CloneWithAuthentication(
            ValidOptions(),
            new AuthenticationOptions
            {
                SqlitePath = Path.Combine(Path.GetTempPath(), $"mxgw-data-{Guid.NewGuid():N}", "gateway-auth.db"),
            });

        ValidateOptionsResult result =
            new GatewayOptionsValidator(contentRootPath: contentRoot).Validate(null, options);

        Assert.True(result.Succeeded);
    }

    /// <summary>
    /// Verifies rooting is host-meaningful (SEC-33): a Windows drive-qualified literal fails on a
    /// Unix host (where it is not rooted) rather than being blessed and written as a junk-named
    /// relative file. On Windows the same literal is genuinely rooted and passes.
    /// </summary>
    [Fact]
    public void Validate_SqlitePath_RootingIsHostMeaningful()
    {
        const string windowsLiteral = @"C:\ProgramData\MxGateway\gateway-auth.db";
        GatewayOptions options = CloneWithAuthentication(
            ValidOptions(),
            new AuthenticationOptions { SqlitePath = windowsLiteral });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);

        if (OperatingSystem.IsWindows())
        {
            Assert.True(result.Succeeded);
        }
        else
        {
            Assert.True(result.Failed);
            Assert.Contains(
                result.Failures!,
                f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
        }
    }

    /// <summary>
    /// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails (GWC-24 rider): the value
    /// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
    /// </summary>
    [Fact]
    public void Validate_Fails_WhenQueueCapacityExceedsUpperBound()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { QueueCapacity = (int.MaxValue / 2) + 1 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Events:QueueCapacity"));
    }

    /// <summary>Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary).</summary>
    [Fact]
    public void Validate_Succeeds_WhenQueueCapacityAtUpperBound()
    {
        GatewayOptions options = CloneWithEvents(
            ValidOptions(),
            new EventOptions { QueueCapacity = int.MaxValue / 2 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
    {
        GatewayOptions options = CloneWithTls(
            ValidOptions(),
            new TlsOptions { SelfSignedCertPath = "gateway-selfsigned.pfx" });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Tls:SelfSignedCertPath") && f.Contains("rooted"));
    }

    private static GatewayOptions CloneWithDashboard(GatewayOptions source, DashboardOptions dashboard)
        => new()
        {
            Authentication = source.Authentication,
            Ldap = source.Ldap,
            Worker = source.Worker,
            Sessions = source.Sessions,
            Events = source.Events,
            Dashboard = dashboard,
            Protocol = source.Protocol,
            Alarms = source.Alarms,
            Tls = source.Tls,
        };

    /// <summary>Verifies <see cref="DashboardOptions.DisableLogin"/> set to <see langword="true"/> aborts startup in Production.</summary>
    [Fact]
    public void Validate_Fails_WhenDisableLoginTrueInProduction()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions { DisableLogin = true });
        ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Dashboard:DisableLogin") && f.Contains("Production"));
    }

    /// <summary>Verifies <see cref="DashboardOptions.DisableLogin"/> set to <see langword="true"/> is accepted outside Production.</summary>
    [Fact]
    public void Validate_Succeeds_WhenDisableLoginTrueInDevelopment()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions { DisableLogin = true });
        ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a populated GroupToTag map with well-formed tags passes validation.</summary>
    [Fact]
    public void Validate_Succeeds_WhenGroupToTagWellFormed()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions
            {
                GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
                {
                    ["GwViewer"] = ["team-a"],
                    ["TeamBViewers"] = ["team-b", "team-c"],
                },
            });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>
    /// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag
    /// but no role (and vice versa) is a legal configuration.
    /// </summary>
    [Fact]
    public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions
            {
                GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
                {
                    ["GwAdmin"] = "Administrator",
                },
                GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
                {
                    ["TeamBViewers"] = ["team-b"],
                },
            });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a blank GroupToTag key (LDAP group name) fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenGroupToTagKeyIsBlank()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions
            {
                GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
                {
                    ["   "] = ["team-a"],
                },
            });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Dashboard:GroupToTag") && f.Contains("non-blank"));
    }

    /// <summary>Verifies a blank tag entry fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenGroupToTagContainsBlankTag()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions
            {
                GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
                {
                    ["GwViewer"] = ["team-a", " "],
                },
            });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("non-blank"));
    }

    /// <summary>Verifies a null tag list (e.g. <c>"GwViewer": null</c> in JSON) fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenGroupToTagValueIsNull()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions
            {
                GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
                {
                    ["GwViewer"] = null!,
                },
            });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("null"));
    }

    /// <summary>Verifies both defined <see cref="UntaggedSessionVisibility"/> values pass validation.</summary>
    /// <param name="visibility">The visibility value under test.</param>
    [Theory]
    [InlineData(UntaggedSessionVisibility.AdminOnly)]
    [InlineData(UntaggedSessionVisibility.AllViewers)]
    public void Validate_Succeeds_ForDefinedUntaggedSessionVisibility(UntaggedSessionVisibility visibility)
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions { UntaggedSessionVisibility = visibility });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies an out-of-range <see cref="UntaggedSessionVisibility"/> fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenUntaggedSessionVisibilityUndefined()
    {
        GatewayOptions options = CloneWithDashboard(
            ValidOptions(),
            new DashboardOptions { UntaggedSessionVisibility = (UntaggedSessionVisibility)42 });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Dashboard:UntaggedSessionVisibility"));
    }

    /// <summary>Verifies the shipped default for untagged sessions is the strict AdminOnly.</summary>
    [Fact]
    public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly()
    {
        Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility);
        Assert.Empty(new DashboardOptions().GroupToTag);
    }

    /// <summary>Verifies plaintext LDAP transport (None) aborts startup in Production.</summary>
    [Fact]
    public void Validate_Fails_WhenLdapTransportNoneInProduction()
    {
        // The class default LDAP options ship Transport=None + AllowInsecure=true (dev posture).
        ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, ValidOptions());
        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Ldap:Transport") && f.Contains("Production"));
    }

    /// <summary>Verifies plaintext LDAP transport (None) is accepted outside Production.</summary>
    [Fact]
    public void Validate_Succeeds_WhenLdapTransportNoneInDevelopment()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, ValidOptions());
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies secure LDAP transport (Ldaps) passes validation in Production.</summary>
    [Fact]
    public void Validate_Succeeds_WhenLdapTransportLdapsInProduction()
    {
        GatewayOptions options = CloneWithLdap(
            ValidOptions(),
            new LdapOptions
            {
                Enabled = true,
                Transport = LdapTransport.Ldaps,
                AllowInsecure = false,
                ServiceAccountPassword = TestBindPassword,
            });
        ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
        Assert.True(result.Succeeded);
    }

    /// <summary>
    /// Verifies enabled LDAP with a blank <see cref="LdapOptions.ServiceAccountPassword"/> fails
    /// validation. Locks in the fail-closed design default (blank) introduced when the bind password
    /// moved to the encrypted secrets store (<c>${secret:ldap/mxgateway/bind}</c>): a blanked/unresolved
    /// password must abort startup rather than silently binding with an empty credential.
    /// </summary>
    [Fact]
    public void Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank()
    {
        GatewayOptions options = CloneWithLdap(
            ValidOptions(),
            new LdapOptions { Enabled = true, ServiceAccountPassword = string.Empty });
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
        Assert.True(result.Failed);
        string failure = Assert.Single(
            result.Failures!,
            f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));

        // SEC-36: the message must steer the operator to the two supported out-of-band channels
        // (dev user-secrets, deployed env var) so a blanked/unresolved credential never gets
        // "fixed" by re-committing a value.
        Assert.Contains("dotnet user-secrets set", failure);
        Assert.Contains("MxGateway__Ldap__ServiceAccountPassword", failure);
    }

    private static GatewayOptions WithSecurity(SecurityOptions security)
        => new() { Security = security, Ldap = ValidLdapOptions() };

    /// <summary>Verifies the default security options pass validation.</summary>
    [Fact]
    public void Validate_Succeeds_WithDefaultSecurityOptions()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, WithSecurity(new SecurityOptions()));
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a zero verification-cache TTL is allowed (disables caching).</summary>
    [Fact]
    public void Validate_Succeeds_WhenVerificationCacheSecondsZero()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = 0 }));
        Assert.True(result.Succeeded);
    }

    /// <summary>Verifies a negative verification-cache TTL fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenVerificationCacheSecondsNegative()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = -1 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("ApiKeyVerificationCacheSeconds"));
    }

    /// <summary>Verifies a negative last-used coalesce window fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenLastUsedCoalesceSecondsNegative()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyLastUsedCoalesceSeconds = -5 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("ApiKeyLastUsedCoalesceSeconds"));
    }

    /// <summary>Verifies a zero login rate-limit permit fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenLoginRateLimitPermitLimitZero()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { LoginRateLimitPermitLimit = 0 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitPermitLimit"));
    }

    /// <summary>Verifies a zero login rate-limit window fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenLoginRateLimitWindowSecondsZero()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { LoginRateLimitWindowSeconds = 0 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitWindowSeconds"));
    }

    /// <summary>Verifies a zero API-key failure limit fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenApiKeyFailureLimitZero()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyFailureLimit = 0 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureLimit"));
    }

    /// <summary>Verifies a zero tracked-peer cap fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenApiKeyFailureTrackedPeersZero()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyFailureTrackedPeers = 0 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureTrackedPeers"));
    }

    /// <summary>Verifies a negative per-key aggregate failure limit fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenApiKeyFailureAggregateLimitNegative()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyFailureAggregateLimit = -1 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureAggregateLimit"));
    }

    /// <summary>Verifies a negative probe interval fails validation.</summary>
    [Fact]
    public void Validate_Fails_WhenApiKeyFailureProbeIntervalSecondsNegative()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions { ApiKeyFailureProbeIntervalSeconds = -1 }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureProbeIntervalSeconds"));
    }

    /// <summary>
    /// Zero is a supported (documented) value for both new limiter knobs: it disables the aggregate
    /// layer and probe admission respectively, so validation must accept it.
    /// </summary>
    [Fact]
    public void Validate_Succeeds_WhenAggregateLimitAndProbeIntervalAreZero()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithSecurity(new SecurityOptions
            {
                ApiKeyFailureAggregateLimit = 0,
                ApiKeyFailureProbeIntervalSeconds = 0,
            }));
        Assert.True(result.Succeeded);
    }

    private static GatewayOptions WithWorkerAndProtocol(WorkerOptions worker, ProtocolOptions protocol)
    {
        GatewayOptions source = ValidOptions();
        return new GatewayOptions
        {
            Authentication = source.Authentication,
            Ldap = source.Ldap,
            Worker = worker,
            Sessions = source.Sessions,
            Events = source.Events,
            Dashboard = source.Dashboard,
            Protocol = protocol,
            Alarms = source.Alarms,
            Tls = source.Tls,
        };
    }

    /// <summary>
    /// Verifies the default worker-frame maximum keeps the required envelope-overhead reserve above
    /// the default public gRPC cap, so a stock configuration passes the headroom check.
    /// </summary>
    [Fact]
    public void Validate_Succeeds_WhenWorkerFrameMaxHasEnvelopeHeadroom()
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
        Assert.True(result.Succeeded);
    }

    /// <summary>
    /// Verifies that a worker-frame maximum equal to the gRPC cap (zero headroom) fails validation:
    /// a maximally-sized accepted gRPC payload would not fit one worker frame once wrapped in a
    /// WorkerEnvelope, faulting the whole session on the outbound write.
    /// </summary>
    [Fact]
    public void Validate_Fails_WhenWorkerFrameMaxEqualsGrpcMaxWithoutHeadroom()
    {
        const int grpcMax = 16 * 1024 * 1024;
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithWorkerAndProtocol(
                new WorkerOptions { MaxMessageBytes = grpcMax },
                new ProtocolOptions { MaxGrpcMessageBytes = grpcMax }));
        Assert.True(result.Failed);
        Assert.Contains(result.Failures!, f => f.Contains("MaxMessageBytes") && f.Contains("reserve"));
    }

    /// <summary>
    /// Verifies the shipped worker event-queue capacity default passes validation and still matches
    /// the worker-side <c>MxAccessEventQueue.DefaultCapacity</c> the environment variable falls back
    /// to when the launcher value is unusable.
    /// </summary>
    [Fact]
    public void Validate_Succeeds_WithDefaultEventQueueCapacity()
    {
        Assert.Equal(10000, new WorkerOptions().EventQueueCapacity);

        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
        Assert.True(result.Succeeded);
    }

    /// <summary>
    /// Verifies a worker event-queue capacity outside the supported range fails validation. Too
    /// small leaves no burst headroom (an overflow faults the whole session); too large commits the
    /// 32-bit worker to an outsized pre-allocation.
    /// </summary>
    /// <param name="eventQueueCapacity">Capacity under test.</param>
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(999)]
    [InlineData(1_000_001)]
    public void Validate_Fails_WhenEventQueueCapacityOutOfRange(int eventQueueCapacity)
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithWorkerAndProtocol(
                new WorkerOptions { EventQueueCapacity = eventQueueCapacity },
                new ProtocolOptions()));

        Assert.True(result.Failed);
        Assert.Contains(
            result.Failures!,
            f => f.Contains("MxGateway:Worker:EventQueueCapacity", StringComparison.Ordinal));
    }

    /// <summary>Verifies the worker event-queue capacity bounds themselves are accepted.</summary>
    /// <param name="eventQueueCapacity">Capacity under test.</param>
    [Theory]
    [InlineData(1000)]
    [InlineData(1_000_000)]
    public void Validate_Succeeds_AtEventQueueCapacityBounds(int eventQueueCapacity)
    {
        ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
            null,
            WithWorkerAndProtocol(
                new WorkerOptions { EventQueueCapacity = eventQueueCapacity },
                new ProtocolOptions()));

        Assert.True(result.Succeeded);
    }
}
