using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace ZB.MOM.WW.OtOpcUa.Cluster;

/// <summary>
///     Fails the host at startup when a node configured for the <b>split topology</b> (per-cluster
///     mesh, Phase 6) is left on a DistributedPubSub transport mode that cannot cross the mesh
///     boundary.
/// </summary>
/// <remarks>
///     <para>
///         Phase 6 splits the single fleet mesh into one 2-node mesh per application cluster. The
///         Phase 2/3/5 dark switches keep their DPS branches alive, but DPS is mesh-wide — it delivers
///         nothing between a central node and a site node that no longer share a gossip ring. The
///         marker of the split topology is a <c>cluster-{ClusterId}</c> role
///         (<see cref="RoleParser.IsClusterRole"/>); those roles exist only post-Phase-6. A node that
///         carries one <b>must</b> use the mesh-crossing transports, and this validator refuses to
///         boot it otherwise — the alternative is a node that runs, listens, and silently delivers no
///         commands or telemetry, which has no stack trace and no failing node to point at.
///     </para>
///     <para>
///         <b>The rule is conditional and must stay that way.</b> It fires only when this node's
///         <i>effective</i> Akka roles contain at least one cluster-scoped role — resolved the same
///         way the node resolves them (<c>Cluster:Roles</c>, falling back to <c>OTOPCUA_ROLES</c> when
///         that is empty, normalized case-insensitively). A legacy / single-mesh / test node has none,
///         so DPS is still legitimate and the validator passes unconditionally.
///     </para>
///     <para>
///         <b>Not validated here:</b> <c>ConfigSource:Mode</c>. Phase 4's
///         <see cref="ConfigSourceOptionsValidator"/> already requires <c>FetchAndCache</c> on a
///         driver-only node, and a fused node legitimately stays <c>Direct</c> — duplicating that here
///         would only risk the two rules drifting apart.
///     </para>
/// </remarks>
public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTransportOptions>
{
    private readonly IConfiguration _configuration;

    /// <summary>
    ///     DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
    ///     constructor dependency is safe here. <see cref="IConfiguration"/> — not
    ///     <c>IClusterRoleInfo</c> — is the source of this node's roles and the three transport modes:
    ///     <c>IClusterRoleInfo</c>'s implementation needs the <c>ActorSystem</c>, which does not exist
    ///     yet at <c>ValidateOnStart</c> time. Reading the modes from configuration too (rather than
    ///     from three injected <c>IOptions</c>) mirrors how the sibling validators cross-read
    ///     <c>Cluster:Roles</c>, and avoids a service-resolution ordering dependency at validation
    ///     time.
    /// </summary>
    public SplitTopologyTransportValidator(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    /// <inheritdoc />
    public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
    {
        // The options instance is intentionally unused: every value this rule depends on is cross-read
        // from IConfiguration, the same way ConfigSourceOptionsValidator reads Cluster:Roles.
        //
        // Resolve the EFFECTIVE Akka roles exactly as the node does, or this "fail loud" rule fires on
        // the wrong role view and passes silently on the two shapes it exists to catch:
        //   (a) Roles-source divergence. AkkaClusterOptions.Roles binds Cluster:Roles but falls back to
        //       OTOPCUA_ROLES (via RoleParser.Parse — the same call Program.cs makes) when Cluster:Roles
        //       is empty. A node configured with only OTOPCUA_ROLES=driver,cluster-SITE-A genuinely
        //       carries the cluster role in Akka, so it must be validated too.
        //   (b) Case. The Cluster:Roles bind path is verbatim (no lowercasing), whereas RoleParser.Parse
        //       lowercases the OTOPCUA_ROLES path. Normalize BOTH here (trim + ToLowerInvariant) so
        //       'Cluster-SITE-A' / 'Driver' / 'Admin' cannot slip past the ordinal IsClusterRole /
        //       driver / admin checks.
        var configured = _configuration.GetSection("Cluster:Roles").Get<string[]>() ?? Array.Empty<string>();
        var effective = configured.Length > 0
            ? configured
            : RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));

        var normalized = Array.ConvertAll(effective, r => r.Trim().ToLowerInvariant());

        var clusterRoleIndex = Array.FindIndex(normalized, RoleParser.IsClusterRole);
        if (clusterRoleIndex < 0)
        {
            // No cluster-{ClusterId} role → not the split topology → DPS is legitimate. This is the
            // exemption that keeps the validator a no-op for every legacy / single-mesh / test node.
            return ValidateOptionsResult.Success;
        }

        // Classification is normalized (above); the ClusterId in the message is taken from the
        // operator's original spelling so it names what they actually wrote.
        var clusterId = RoleParser.ClusterIdFromRole(effective[clusterRoleIndex].Trim());
        var isDriver = Array.IndexOf(normalized, RoleParser.Driver) >= 0;
        var isAdmin = Array.IndexOf(normalized, RoleParser.Admin) >= 0;

        var errors = new List<string>();

        // Modes are read from configuration directly; an unset key inherits the class default (Dps for
        // all three), so a split node that simply never set the key fails exactly as an explicit Dps
        // would — which is the point.
        var meshMode = _configuration["MeshTransport:Mode"] ?? MeshTransportOptions.ModeDps;
        var telemetryMode = _configuration["Telemetry:Mode"] ?? TelemetryOptions.ModeDps;
        var telemetryDialMode = _configuration["TelemetryDial:Mode"] ?? TelemetryDialOptions.ModeDps;

        if (!string.Equals(meshMode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase))
        {
            errors.Add(
                $"Cluster:Roles declares a cluster-{clusterId} role (split topology) but "
                + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
                + $"'{meshMode}'; a split mesh cannot use DistributedPubSub — set "
                + $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)}="
                + $"{MeshTransportOptions.ModeClusterClient}.");
        }

        if (isDriver
            && !string.Equals(telemetryMode, TelemetryOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase))
        {
            errors.Add(
                $"Cluster:Roles declares a cluster-{clusterId} role (split topology) with 'driver' but "
                + $"{TelemetryOptions.SectionName}:{nameof(TelemetryOptions.Mode)} is "
                + $"'{telemetryMode}'; a split-topology driver node hosts the telemetry gRPC server — "
                + $"set {TelemetryOptions.SectionName}:{nameof(TelemetryOptions.Mode)}="
                + $"{TelemetryOptions.ModeGrpc}.");
        }

        if (isAdmin
            && !string.Equals(telemetryDialMode, TelemetryDialOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase))
        {
            errors.Add(
                $"Cluster:Roles declares a cluster-{clusterId} role (split topology) with 'admin' but "
                + $"{TelemetryDialOptions.SectionName}:{nameof(TelemetryDialOptions.Mode)} is "
                + $"'{telemetryDialMode}'; a split-topology central node dials nodes for telemetry — set "
                + $"{TelemetryDialOptions.SectionName}:{nameof(TelemetryDialOptions.Mode)}="
                + $"{TelemetryDialOptions.ModeGrpc}.");
        }

        return errors.Count == 0
            ? ValidateOptionsResult.Success
            : ValidateOptionsResult.Fail(string.Join(" ", errors));
    }
}
