using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Server.Workers;

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

public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
{
    private const int MinimumMaxMessageBytes = 1024;
    private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;

    // Bounds on the worker's outbound event-queue capacity. The floor keeps enough headroom that a
    // normal subscription burst cannot overflow the queue (an overflow faults the whole session);
    // the ceiling keeps a mistyped value from committing the x86 worker to an unbounded backlog.
    private const int MinimumWorkerEventQueueCapacity = 1000;
    private const int MaximumWorkerEventQueueCapacity = 1_000_000;

    // Whether the host is running in the Production environment. Drives the production-only
    // hard-stops (dashboard login disabled, plaintext LDAP transport) that must abort startup
    // rather than merely warn. Non-production hosts keep the permissive dev posture.
    private readonly bool _isProduction;

    // The application content root. Store paths must not live under it — see
    // GatewayConfigPathRules.AddIfUnderContentRoot. Null for non-DI callers that supply no
    // environment, which skips the rule rather than inventing a root to test against.
    private readonly string? _contentRootPath;

    /// <summary>
    /// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for the
    /// dependency-injection path, deriving the production posture and content root from the host
    /// environment.
    /// </summary>
    /// <param name="environment">The host environment.</param>
    public GatewayOptionsValidator(IHostEnvironment environment)
    {
        ArgumentNullException.ThrowIfNull(environment);
        _isProduction = environment.IsProduction();
        _contentRootPath = environment.ContentRootPath;
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for unit
    /// tests and non-DI callers. Defaults to a non-production posture so the production-only
    /// hard-stops do not fire; pass <see langword="true"/> to exercise them.
    /// </summary>
    /// <param name="isProduction">Whether to treat the host as running in Production.</param>
    /// <param name="contentRootPath">
    /// Content root to test store paths against; <see langword="null"/> leaves the content-root
    /// rule inactive, which is what a caller with no real host wants.
    /// </param>
    internal GatewayOptionsValidator(bool isProduction = false, string? contentRootPath = null)
    {
        _isProduction = isProduction;
        _contentRootPath = contentRootPath;
    }

    /// <inheritdoc />
    protected override void Validate(ValidationBuilder builder, GatewayOptions options)
    {
        ValidateAuthentication(options.Authentication, _contentRootPath, builder);
        ValidateLdap(options.Ldap, builder, _isProduction);
        ValidateWorker(options.Worker, builder);
        ValidateSessions(options.Sessions, builder);
        ValidateEvents(options.Events, builder);
        ValidateDashboard(options.Dashboard, builder, _isProduction);
        ValidateProtocol(options.Protocol, builder);
        ValidateFrameSizeHeadroom(options.Worker, options.Protocol, builder);
        ValidateAlarms(options.Alarms, builder);
        ValidateTls(options.Tls, builder);
        ValidateSecurity(options.Security, builder);
    }

    private static void ValidateSecurity(SecurityOptions options, ValidationBuilder builder)
    {
        // Cache/coalesce windows may be 0 (disables that mechanism); negatives are invalid.
        AddIfNegative(
            options.ApiKeyVerificationCacheSeconds,
            "MxGateway:Security:ApiKeyVerificationCacheSeconds must be greater than or equal to zero (0 disables the verification cache).",
            builder);
        AddIfNegative(
            options.ApiKeyLastUsedCoalesceSeconds,
            "MxGateway:Security:ApiKeyLastUsedCoalesceSeconds must be greater than or equal to zero (0 forwards every last_used write).",
            builder);

        // Rate-limit knobs must be positive: a zero permit/window/limit is a misconfiguration that
        // would either reject every request or divide by zero rather than express an intent.
        AddIfNotPositive(
            options.LoginRateLimitPermitLimit,
            "MxGateway:Security:LoginRateLimitPermitLimit must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.LoginRateLimitWindowSeconds,
            "MxGateway:Security:LoginRateLimitWindowSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.ApiKeyFailureLimit,
            "MxGateway:Security:ApiKeyFailureLimit must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.ApiKeyFailureWindowSeconds,
            "MxGateway:Security:ApiKeyFailureWindowSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.ApiKeyFailureTrackedPeers,
            "MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.",
            builder);

        // Retention must be at least one day: 0 would sweep the audit table on every pass, which
        // is a way to silently disable auditing rather than an expression of intent.
        AddIfNotPositive(
            options.AuditRetentionDays,
            "MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
            builder);

        // The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate
        // limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
        // Negatives express no intent.
        AddIfNegative(
            options.ApiKeyFailureAggregateLimit,
            "MxGateway:Security:ApiKeyFailureAggregateLimit must be greater than or equal to zero (0 disables the per-key aggregate layer).",
            builder);
        AddIfNegative(
            options.ApiKeyFailureProbeIntervalSeconds,
            "MxGateway:Security:ApiKeyFailureProbeIntervalSeconds must be greater than or equal to zero (0 blocks absolutely instead of admitting probes).",
            builder);
    }

    private static void ValidateAuthentication(
        AuthenticationOptions options,
        string? contentRootPath,
        ValidationBuilder builder)
    {
        if (!Enum.IsDefined(options.Mode))
        {
            builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
            return;
        }

        if (options.Mode == AuthenticationMode.ApiKey)
        {
            AddIfBlank(
                options.SqlitePath,
                "MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
                builder);
            AddIfInvalidPath(
                options.SqlitePath,
                "MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
                builder);
            AddIfNotRooted(
                options.SqlitePath,
                "MxGateway:Authentication:SqlitePath must be an absolute (rooted) path so the credential store never lands in the launch working directory.",
                builder);
            AddIfUnderContentRoot(
                options.SqlitePath,
                contentRootPath,
                $"MxGateway:Authentication:SqlitePath must not be inside the application directory ({contentRootPath}). The upgrade procedure renames that directory, which abandons the credential store and silently starts an empty one — every API key is lost and no client can authenticate.",
                builder);
            AddIfBlank(
                options.PepperSecretName,
                "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
                builder);
        }
    }

    private static void ValidateLdap(LdapOptions options, ValidationBuilder builder, bool isProduction)
    {
        if (!options.Enabled)
        {
            return;
        }

        AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
        AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
        AddIfBlank(
            options.ServiceAccountDn,
            "MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
            builder);
        AddIfBlank(
            options.ServiceAccountPassword,
            "MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled. "
                + "Never commit it: on dev boxes set user-secrets "
                + "(dotnet user-secrets set \"MxGateway:Ldap:ServiceAccountPassword\" <value>); "
                + "on deployed hosts set the environment variable "
                + "MxGateway__Ldap__ServiceAccountPassword. "
                + "(appsettings.json ships the ${secret:ldap/mxgateway/bind} store reference as the default.)",
            builder);
        AddIfBlank(
            options.UserNameAttribute,
            "MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
            builder);
        AddIfBlank(
            options.DisplayNameAttribute,
            "MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
            builder);
        AddIfBlank(
            options.GroupAttribute,
            "MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
            builder);
        builder.Port(options.Port, "MxGateway:Ldap:Port");

        if (options.Transport == LdapTransport.None && !options.AllowInsecure)
        {
            builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
        }

        // Production hard-stop: plaintext LDAP binds send the operator's password in the clear.
        // The permissive dev default (Transport=None against the shared GLAuth instance) is
        // acceptable off-production but must never ship to a Production host. Deployed hosts must
        // set Transport=Ldaps/StartTls; see docs/GatewayConfiguration.md and glauth.md.
        if (isProduction && options.Transport == LdapTransport.None)
        {
            builder.Add(
                "MxGateway:Ldap:Transport must not be None (plaintext) in the Production environment; use Ldaps or StartTls.");
        }
    }

    private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
    {
        AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
        AddIfInvalidPath(
            options.ExecutablePath,
            "MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
            builder);

        if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
            && !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
        {
            builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
        }

        if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
        {
            AddIfInvalidPath(
                options.WorkingDirectory,
                "MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
                builder);
        }

        if (!Enum.IsDefined(options.RequiredArchitecture))
        {
            builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
        }

        AddIfNotPositive(
            options.StartupTimeoutSeconds,
            "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.StartupProbeRetryAttempts,
            "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.StartupProbeRetryDelayMilliseconds,
            "MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.PipeConnectAttemptTimeoutMilliseconds,
            "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
            builder);
        if (options.WriteCompletionWaitMilliseconds < 0)
        {
            builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
        }
        AddIfNotPositive(
            options.ShutdownTimeoutSeconds,
            "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.HeartbeatIntervalSeconds,
            "MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.HeartbeatGraceSeconds,
            "MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
            builder);

        if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
        {
            builder.Add(
                "MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
        }

        if (options.EventQueueCapacity is < MinimumWorkerEventQueueCapacity or > MaximumWorkerEventQueueCapacity)
        {
            builder.Add(
                $"MxGateway:Worker:EventQueueCapacity must be between {MinimumWorkerEventQueueCapacity} and {MaximumWorkerEventQueueCapacity}.");
        }

        if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
        {
            builder.Add(
                $"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
        }
    }

    private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
    {
        AddIfNotPositive(
            options.DefaultCommandTimeoutSeconds,
            "MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
        AddIfNotPositive(
            options.MaxPendingCommandsPerSession,
            "MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.DefaultLeaseSeconds,
            "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.LeaseSweepIntervalSeconds,
            "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            options.MaxEventSubscribersPerSession,
            "MxGateway:Sessions:MaxEventSubscribersPerSession must be greater than zero.",
            builder);
        AddIfNegative(
            options.DetachGraceSeconds,
            "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).",
            builder);
        AddIfNegative(
            options.FaultedGraceSeconds,
            "MxGateway:Sessions:FaultedGraceSeconds must be zero or greater (0 reaps a faulted session on the next sweep).",
            builder);
        AddIfNegative(
            options.WorkerReadyWaitTimeoutMs,
            "MxGateway:Sessions:WorkerReadyWaitTimeoutMs must be greater than or equal to zero.",
            builder);

        // NOTE: We intentionally do NOT reject !AllowMultipleEventSubscribers &&
        // MaxEventSubscribersPerSession > 1 as a hard validation error here. The default
        // SessionOptions ships with AllowMultipleEventSubscribers=false and
        // MaxEventSubscribersPerSession=8; making those defaults a validation failure would
        // break every deployment that has not explicitly set the cap. The cap is simply
        // ignored in single-subscriber mode (AttachEventSubscriber derives effectiveCap=1),
        // so the only practical consequence of the apparent inconsistency is a dead config
        // knob, not incorrect behavior.
    }

    // QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
    // sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
    // cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
    // upper-bound pattern.
    private const int MaximumEventQueueCapacity = int.MaxValue / 2;

    private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
    {
        AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);

        if (options.QueueCapacity > MaximumEventQueueCapacity)
        {
            builder.Add(
                $"MxGateway:Events:QueueCapacity must be less than or equal to {MaximumEventQueueCapacity} "
                + "so the derived worker event-staging channel (2 x QueueCapacity) cannot overflow.");
        }

        if (!Enum.IsDefined(options.BackpressurePolicy))
        {
            builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
        }

        // ReplayBufferCapacity and ReplayRetentionSeconds are bounds on the replay ring
        // buffer; 0 is a valid value (disables that dimension), so only negatives fail.
        AddIfNegative(
            options.ReplayBufferCapacity,
            "MxGateway:Events:ReplayBufferCapacity must be greater than or equal to zero.",
            builder);
        builder.RequireThat(
            options.ReplayRetentionSeconds >= 0,
            "MxGateway:Events:ReplayRetentionSeconds must be greater than or equal to zero.");

        builder.RequireThat(
            options.MaxSparseArrayLength >= 1 && options.MaxSparseArrayLength <= Array.MaxLength,
            $"MxGateway:Events:MaxSparseArrayLength must be between 1 and {Array.MaxLength}.");
    }

    private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder, bool isProduction)
    {
        // Production hard-stop: DisableLogin swaps in an auto-login handler that authenticates
        // EVERY request (remote included) as AutoLoginUser holding both roles, turning the whole
        // dashboard — API-key CRUD and worker Kill included — into an unauthenticated admin
        // surface on a 0.0.0.0-bound port. It is a dev/test-only convenience; abort startup if it
        // is set in Production. Non-production hosts keep the existing runtime warning.
        if (isProduction && options.DisableLogin)
        {
            builder.Add(
                "MxGateway:Dashboard:DisableLogin must not be true in the Production environment; it disables all dashboard authentication.");
        }

        // GroupToRole shape is validated even when the dashboard is disabled so
        // misconfiguration surfaces at startup; emptiness is allowed, with the
        // consequence that no LDAP user can sign in (login returns "no roles
        // mapped"). Operators who disable the dashboard or want a closed
        // deployment can ship without a mapping.
        foreach (KeyValuePair<string, string> entry in options.GroupToRole)
        {
            if (string.IsNullOrWhiteSpace(entry.Key))
            {
                builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
            }

            if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
                && !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
            {
                builder.Add(
                    $"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
            }
        }

        // GroupToTag is validated for shape only, and independently of GroupToRole:
        // a group may grant a role, a tag, both, or neither. An empty map is legal —
        // it yields Viewers with no tag grant, which (under the default AdminOnly)
        // means they observe no session's events. That is the fail-closed posture.
        foreach (KeyValuePair<string, string[]> entry in options.GroupToTag)
        {
            if (string.IsNullOrWhiteSpace(entry.Key))
            {
                builder.Add("MxGateway:Dashboard:GroupToTag keys (LDAP group names) must be non-blank.");
            }

            if (entry.Value is null)
            {
                builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] must be a list of tags, not null.");
                continue;
            }

            if (Array.Exists(entry.Value, string.IsNullOrWhiteSpace))
            {
                builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] tags must be non-blank.");
            }
        }

        if (!Enum.IsDefined(options.UntaggedSessionVisibility))
        {
            builder.Add(
                $"MxGateway:Dashboard:UntaggedSessionVisibility must be '{nameof(UntaggedSessionVisibility.AdminOnly)}' "
                + $"or '{nameof(UntaggedSessionVisibility.AllViewers)}'.");
        }

        AddIfNotPositive(
            options.SnapshotIntervalMilliseconds,
            "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
            builder);
        AddIfNegative(
            options.RecentFaultLimit,
            "MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
            builder);
        AddIfNegative(
            options.RecentSessionLimit,
            "MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
            builder);
    }

    private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];

    private const int MinimumAlarmPollIntervalMilliseconds = 100;

    // One hour. Above this the cadence stops being a cadence: int.MaxValue
    // milliseconds is ~24 days, which silently disables alarm polling instead
    // of reporting the misconfiguration.
    private const int MaximumAlarmPollIntervalMilliseconds = 3_600_000;

    private const int MinimumMaxAlarmsPerFetch = 64;

    // The worker is a 32-bit process and materializes each fetch as one BSTR
    // plus a full XmlDocument over it, so an unbounded cap is an out-of-memory
    // fault on the STA rather than a slow poll.
    private const int MaximumMaxAlarmsPerFetch = 65_536;

    private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
    {
        // Validated regardless of Enabled: both values are stamped onto every
        // worker launch environment, so a bad value is a misconfiguration even
        // before the central monitor is switched on.
        if (options.PollIntervalMilliseconds is < MinimumAlarmPollIntervalMilliseconds
            or > MaximumAlarmPollIntervalMilliseconds)
        {
            builder.Add(
                $"MxGateway:Alarms:PollIntervalMilliseconds must be between {MinimumAlarmPollIntervalMilliseconds} and {MaximumAlarmPollIntervalMilliseconds}.");
        }

        if (options.MaxAlarmsPerFetch is < MinimumMaxAlarmsPerFetch or > MaximumMaxAlarmsPerFetch)
        {
            builder.Add(
                $"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
        }

        if (!options.Enabled)
        {
            return;
        }

        // When the central alarm monitor is enabled, it needs either a canonical
        // SubscriptionExpression or a DefaultArea to compose one from. Validating
        // it at startup makes the misconfiguration fail-fast at boot, in line
        // with every other section.
        if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
            && string.IsNullOrWhiteSpace(options.DefaultArea))
        {
            builder.Add(
                "MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
        }

        if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
            && !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
        {
            builder.Add(
                @"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
        }

        ValidateAlarmFallback(options.Fallback, builder);
    }

    private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
    {
        // Validate Mode is one of the recognised values (case-insensitive).
        bool modeValid = Array.Exists(
            ValidAlarmFallbackModes,
            m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));

        if (!modeValid)
        {
            builder.Add(
                $"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
        }

        // ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
        if (modeValid
            && string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
            && !fallback.Discovery.UseGalaxyRepository
            && fallback.Discovery.IncludeAttributes.Length == 0)
        {
            builder.Add(
                "MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
        }

        // Floor validation: numeric thresholds must be at least 1.
        AddIfNotPositive(
            fallback.ConsecutiveFailureThreshold,
            "MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
            builder);
        AddIfNotPositive(
            fallback.FailbackProbeIntervalSeconds,
            "MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
            builder);
        AddIfNotPositive(
            fallback.FailbackStableProbes,
            "MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
            builder);
    }

    private const int MinimumCertValidityYears = 1;
    private const int MaximumCertValidityYears = 100;

    private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
    {
        if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
        {
            builder.Add(
                $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
        }

        // The default is non-blank, so this only catches an explicitly-blanked path.
        AddIfBlank(
            options.SelfSignedCertPath,
            "MxGateway:Tls:SelfSignedCertPath must not be blank.",
            builder);
        AddIfInvalidPath(
            options.SelfSignedCertPath,
            "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
            builder);
        AddIfNotRooted(
            options.SelfSignedCertPath,
            "MxGateway:Tls:SelfSignedCertPath must be an absolute (rooted) path so the generated private key never lands in the launch working directory.",
            builder);

        foreach (string dns in options.AdditionalDnsNames)
        {
            if (string.IsNullOrWhiteSpace(dns))
            {
                builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
            }
        }
    }

    private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
    {
        if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
        {
            builder.Add(
                $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
        }

        if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
        {
            builder.Add(
                $"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
        }
    }

    // The worker-frame (pipe) maximum must stay above the public gRPC cap by the envelope-overhead
    // reserve, otherwise a maximally-sized accepted gRPC payload does not fit one worker frame once
    // wrapped in a WorkerEnvelope and the outbound write faults the whole session. Fail fast
    // at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
    // message is not doubled up with the individual range errors.
    private static void ValidateFrameSizeHeadroom(
        WorkerOptions worker,
        ProtocolOptions protocol,
        ValidationBuilder builder)
    {
        bool workerInRange = worker.MaxMessageBytes is >= MinimumMaxMessageBytes and <= MaximumMaxMessageBytes;
        bool grpcInRange = protocol.MaxGrpcMessageBytes is >= MinimumMaxMessageBytes and <= MaximumMaxMessageBytes;
        if (!workerInRange || !grpcInRange)
        {
            return;
        }

        long requiredWorkerMax =
            (long)protocol.MaxGrpcMessageBytes + WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes;
        if (worker.MaxMessageBytes < requiredWorkerMax)
        {
            builder.Add(
                $"MxGateway:Worker:MaxMessageBytes ({worker.MaxMessageBytes}) must be at least "
                + $"MxGateway:Protocol:MaxGrpcMessageBytes ({protocol.MaxGrpcMessageBytes}) plus the "
                + $"{WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes}-byte worker-frame envelope reserve "
                + $"(>= {requiredWorkerMax}).");
        }
    }

    private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
    {
        builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
    }

    private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
    {
        builder.RequireThat(value > 0, message);
    }

    private static void AddIfNegative(int value, string message, ValidationBuilder builder)
    {
        builder.RequireThat(value >= 0, message);
    }

    // Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
    // host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
    // against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
    // fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
    private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
        => GatewayConfigPathRules.AddIfNotRooted(value, message, builder);

    private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
        => GatewayConfigPathRules.AddIfInvalidPath(value, message, builder);

    // Rooted is not the same as safe: an absolute path inside the app directory passes
    // AddIfNotRooted and is still renamed away by the upgrade procedure. See
    // GatewayConfigPathRules.AddIfUnderContentRoot.
    private static void AddIfUnderContentRoot(
        string? value,
        string? contentRoot,
        string message,
        ValidationBuilder builder)
        => GatewayConfigPathRules.AddIfUnderContentRoot(value, contentRoot, message, builder);
}
