using System.Runtime.CompilerServices;
using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;

[assembly: InternalsVisibleTo("ZB.MOM.WW.Health.Akka.Tests")]

namespace ZB.MOM.WW.Health.Akka;

/// <summary>
/// Active-tier probe: Healthy (200) on the one node in charge, Unhealthy (503) on its standby.
/// Register to the <see cref="ZbHealthTags.Active"/> tag.
/// </summary>
/// <remarks>
///     <para>
///         "In charge" means the <b>oldest Up member</b> — see <see cref="ClusterActiveNode"/> for
///         why leadership is the wrong answer and what breaks when it is used. Scope the competition
///         to a role with <see cref="ActiveNodeHealthCheckOptions.RolePreference"/>; leave it empty
///         for a cluster whose nodes all compete for the same single active slot.
///     </para>
///     <para>
///         <b>Behaviour change in 0.3.0.</b> Through 0.2.1 this check selected by
///         <c>ClusterState.Leader</c> / <c>RoleLeader</c>, and its role-filtered mode reported
///         Healthy for any node <i>lacking</i> the role. Both were wrong, both were found on live
///         rigs, and both Akka consumers had already replaced it with a private copy rather than use
///         it. It now selects by age and reports <see cref="HealthStatus.Unhealthy"/> for a node that
///         owns no active work. A node whose active tier previously answered 200 unconditionally will
///         now answer 503 unless it really is the active node — which is the point.
///     </para>
///     <para>
///         Startup-safe: before the <see cref="ActorSystem"/> or the cluster is available the result
///         is <see cref="HealthStatus.Degraded"/> (a 200 under the tier mapping), so a node that is
///         merely still booting is not reported as a failed standby and pulled from rotation on the
///         way up.
///     </para>
/// </remarks>
public sealed class ActiveNodeHealthCheck : IHealthCheck
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ActiveNodeHealthCheckOptions _options;

    /// <summary>
    /// Unscoped: the active node is the oldest Up member of the whole cluster.
    /// </summary>
    /// <param name="serviceProvider">
    /// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
    /// check is startup-safe.
    /// </param>
    public ActiveNodeHealthCheck(IServiceProvider serviceProvider)
        : this(serviceProvider, new ActiveNodeHealthCheckOptions())
    {
    }

    /// <summary>
    /// Role-scoped: the active node is the oldest Up member carrying <paramref name="role"/>.
    /// </summary>
    /// <param name="serviceProvider">
    /// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
    /// check is startup-safe.
    /// </param>
    /// <param name="role">The cluster role to scope the competition to.</param>
    public ActiveNodeHealthCheck(IServiceProvider serviceProvider, string role)
        : this(serviceProvider, NewOptions(role))
    {
    }

    /// <summary>
    /// Configured with an explicit <see cref="ActiveNodeHealthCheckOptions"/>.
    /// </summary>
    /// <param name="serviceProvider">
    /// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
    /// check is startup-safe.
    /// </param>
    /// <param name="options">The role-scoping options.</param>
    public ActiveNodeHealthCheck(IServiceProvider serviceProvider, ActiveNodeHealthCheckOptions options)
    {
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
        _options = options ?? throw new ArgumentNullException(nameof(options));
    }

    private static ActiveNodeHealthCheckOptions NewOptions(string role)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(role);
        return new ActiveNodeHealthCheckOptions { RolePreference = new[] { role } };
    }

    /// <inheritdoc />
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        var system = _serviceProvider.GetService<ActorSystem>();
        if (system is null)
            return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));

        Member self;
        IEnumerable<Member> members;
        try
        {
            // Reading cluster membership can throw while the ActorSystem exists but the cluster has
            // not finished initialising (e.g. Akka.Cluster not yet configured →
            // ConfigurationException). The spec's startup-safety rule maps this to Degraded rather
            // than letting the exception escape (which the host would record as Unhealthy).
            var cluster = Cluster.Get(system);
            self = cluster.SelfMember;
            members = cluster.State.Members;
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
        }

        var scoped = _options.RolePreference.Count > 0;
        var role = ClusterActiveNode.ResolveActiveRole(self.Roles, _options.RolePreference);

        var data = new Dictionary<string, object>(StringComparer.Ordinal)
        {
            ["selfAddress"] = self.Address.ToString(),
        };
        if (role is not null) data["activeRole"] = role;

        if (scoped && role is null)
        {
            // The node carries none of the preferred roles, so it owns no active work and cannot be
            // the active node for any of them. Reported per NoActiveRoleStatus — Unhealthy by
            // default, because the active tier's whole job is to keep such a node out of the pool.
            var message = $"Node carries none of the active roles: {string.Join(", ", _options.RolePreference)}.";
            return Task.FromResult(new HealthCheckResult(_options.NoActiveRoleStatus, message, exception: null, data));
        }

        var active = ClusterActiveNode.OldestUpMember(members, role);
        if (active is not null) data["activeNode"] = active.Address.ToString();

        var scopeLabel = role is null ? "the cluster" : $"role '{role}'";

        if (active is null)
        {
            // Nobody is Up in the scope — a cold start, or every holder is still Joining or already
            // Leaving. Nobody is active, so nobody may claim to be.
            return Task.FromResult(HealthCheckResult.Unhealthy(
                $"No Up member currently holds the active slot for {scopeLabel}.", exception: null, data));
        }

        // Compared by UniqueAddress: a node that restarted on the same host and port is a different
        // member, and during the overlap the cluster briefly holds both incarnations.
        return Task.FromResult(active.UniqueAddress.Equals(self.UniqueAddress)
            ? HealthCheckResult.Healthy($"Active node for {scopeLabel} (oldest Up member).", data)
            : HealthCheckResult.Unhealthy($"Standby: active node for {scopeLabel} is {active.Address}.", exception: null, data));
    }
}
