using Akka.Actor;
using Akka.Cluster.Hosting;
using Akka.Cluster.Hosting.SBR;
using Akka.Configuration;
using Akka.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;

/// <summary>
/// Guards the downing strategy — the mechanism that decides whether a node that loses its peer
/// keeps running or shuts itself down.
/// </summary>
/// <remarks>
///     <para>
///         <b>Why these tests assert the EFFECTIVE config, not the typed option.</b> The previous
///         version of this file asserted only that
///         <see cref="ServiceCollectionExtensions.BuildClusterOptions"/> returned a
///         <see cref="KeepOldestOption"/>. That is a different question from "what does the running
///         ActorSystem actually do". Akka.Hosting merges several HOCON fragments — the akka.conf
///         resource, whatever <c>WithClustering</c> emits, and anything added explicitly — and which
///         one wins is not readable off the <see cref="HoconAddMode"/> names alone. A downing
///         setting can therefore be right in the resource file, right in the typed options object,
///         and still not be the one in force. These tests start a real host through the production
///         bootstrap and read <c>akka.cluster.downing-provider-class</c> back off
///         <see cref="ActorSystem.Settings"/>, which is the only assertion precedence cannot fool.
///     </para>
///     <para>
///         <b>Why the default is auto-down.</b> A two-node cluster running the SBR
///         <c>keep-oldest</c> resolver cannot survive a crash of the oldest node. Akka.NET 1.5.62's
///         <c>KeepOldest.OldestDecision</c> only lets <c>down-if-alone</c> rescue a side holding
///         &gt;= 2 members, so in a 1-vs-1 split the lone survivor takes <c>DownReachable</c> and
///         downs <i>itself</i> — then <c>run-coordinated-shutdown-when-down</c> terminates it. A
///         crash of one node becomes a total outage: exactly the failure the redundancy pair exists
///         to absorb. Proven live on the sister project's rig (see
///         <c>../ScadaBridge/docs/plans/2026-07-21-auto-down-availability-decision.md</c>) and
///         adopted here for the same reason. The accepted trade is dual-active during a genuine
///         network partition; see <c>docs/Redundancy.md</c>.
///     </para>
/// </remarks>
public sealed class SplitBrainResolverActivationTests
{
    private static AkkaClusterOptions SampleOptions(string? strategy = null)
    {
        var options = new AkkaClusterOptions
        {
            SeedNodes = new[] { "akka.tcp://otopcua@127.0.0.1:4053" },
            Roles = new[] { "admin", "driver" },
        };
        if (strategy is not null)
        {
            options.SplitBrainResolverStrategy = strategy;
        }

        return options;
    }

    /// <summary>
    /// Starts a real host through <see cref="ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap"/>
    /// — the production entry point, not a re-creation of it — and returns the ActorSystem's
    /// effective configuration.
    /// </summary>
    /// <remarks>
    /// Calling the production bootstrap rather than inlining the calls it makes is deliberate: an
    /// earlier draft of this helper rebuilt the same three steps itself, which pinned the
    /// <i>test's</i> wiring instead of the shipped one and would have stayed green through a change
    /// to production's HOCON precedence.
    /// </remarks>
    private static async Task<Config> EffectiveConfigAsync(AkkaClusterOptions options)
    {
        // Port 0 so concurrently-running test hosts cannot collide on 4053, and no seed nodes so the
        // system never tries to join a cluster.
        options.Port = 0;
        options.Hostname = "127.0.0.1";
        options.PublicHostname = "127.0.0.1";
        options.SeedNodes = Array.Empty<string>();

        var builder = Host.CreateDefaultBuilder();
        builder.ConfigureServices(services =>
        {
            services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
            services.AddAkka("otopcua-sbr-test", (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp));
        });

        using var host = builder.Build();
        await host.StartAsync();
        try
        {
            return host.Services.GetRequiredService<ActorSystem>().Settings.Config;
        }
        finally
        {
            await host.StopAsync();
        }
    }

    /// <summary>
    /// The default strategy installs Akka's AutoDowning provider — the leader among the
    /// <i>reachable</i> members downs the unreachable peer, so a crash of either node fails over.
    /// </summary>
    [Fact]
    public async Task Default_strategy_installs_the_auto_downing_provider()
    {
        var config = await EffectiveConfigAsync(SampleOptions());

        config.GetString("akka.cluster.downing-provider-class")
            .ShouldStartWith(
                "Akka.Cluster.AutoDowning, Akka.Cluster",
                Case.Sensitive,
                "the effective downing provider — not merely the configured one — must be AutoDowning, or a crash of the oldest node is a total outage");
    }

    /// <summary>
    /// AutoDowning is inert without a downing window: <c>auto-down-unreachable-after</c> defaults to
    /// <c>off</c>, under which the provider is installed but never downs anything. Selecting the
    /// provider and forgetting the window would look correct and fail over never.
    /// </summary>
    [Fact]
    public async Task Default_strategy_sets_the_auto_down_window()
    {
        var config = await EffectiveConfigAsync(SampleOptions());

        config.GetTimeSpan("akka.cluster.auto-down-unreachable-after")
            .ShouldBe(ServiceCollectionExtensions.DowningStableAfter);
    }

    /// <summary>
    /// The opt-out is real: an operator who chooses partition-safety over availability gets the SBR
    /// keep-oldest resolver back, in force rather than merely configured.
    /// </summary>
    [Fact]
    public async Task Keep_oldest_strategy_installs_the_sbr_provider()
    {
        var config = await EffectiveConfigAsync(SampleOptions("keep-oldest"));

        config.GetString("akka.cluster.downing-provider-class")
            .ShouldStartWith("Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster");
        config.GetString("akka.cluster.split-brain-resolver.active-strategy").ShouldBe("keep-oldest");
        config.GetBoolean("akka.cluster.split-brain-resolver.keep-oldest.down-if-alone").ShouldBeTrue();
    }

    /// <summary>The default is auto-down, so an unconfigured deployment gets the survivable one.</summary>
    [Fact]
    public void Strategy_defaults_to_auto_down()
    {
        new AkkaClusterOptions().SplitBrainResolverStrategy.ShouldBe("auto-down");
    }

    /// <summary>
    /// A typo in the strategy must fail the host at startup, not silently fall through to whichever
    /// provider Akka.Hosting would have installed by default — which is the fatal one.
    /// </summary>
    [Fact]
    public void Unknown_strategy_throws_rather_than_silently_falling_back()
    {
        var ex = Should.Throw<ArgumentOutOfRangeException>(
            () => ServiceCollectionExtensions.BuildDowningHocon(SampleOptions("keep-majority")));

        ex.Message.ShouldContain("keep-majority");
        ex.Message.ShouldContain("auto-down");
    }

    /// <summary>Strategy comparison is case-insensitive — config casing is not a failure mode.</summary>
    [Fact]
    public void Strategy_matching_is_case_insensitive()
    {
        Should.NotThrow(() => ServiceCollectionExtensions.BuildDowningHocon(SampleOptions("Auto-Down")));
        Should.NotThrow(() => ServiceCollectionExtensions.BuildDowningHocon(SampleOptions("Keep-Oldest")));
    }

    /// <summary>
    /// Under keep-oldest the SBR resolver is still set as a typed option, so the strategy stays
    /// explicit in code rather than depending on Akka.Cluster.Hosting's default.
    /// </summary>
    [Fact]
    public void Keep_oldest_sets_the_typed_resolver_option()
    {
        var options = ServiceCollectionExtensions.BuildClusterOptions(SampleOptions("keep-oldest"));

        options.SplitBrainResolver.ShouldBeOfType<KeepOldestOption>().DownIfAlone.ShouldBe(true);
    }

    /// <summary>
    /// Pins the two stability windows together. <c>auto-down-unreachable-after</c> comes from
    /// <see cref="ServiceCollectionExtensions.DowningStableAfter"/> while the SBR resolver reads
    /// <c>stable-after</c> out of akka.conf; changing one and missing the other would leave the two
    /// strategies failing over at different latencies for no stated reason.
    /// </summary>
    [Fact]
    public void Downing_window_matches_the_akka_conf_stable_after()
    {
        var akkaConf = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());

        akkaConf.GetTimeSpan("akka.cluster.split-brain-resolver.stable-after")
            .ShouldBe(ServiceCollectionExtensions.DowningStableAfter);
    }

    /// <summary>
    /// The stability window must stay above the failure detector's tolerance, or a node gets downed
    /// while its peer is merely slow.
    /// </summary>
    [Fact]
    public void Downing_window_exceeds_the_acceptable_heartbeat_pause()
    {
        var akkaConf = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());

        ServiceCollectionExtensions.DowningStableAfter
            .ShouldBeGreaterThan(akkaConf.GetTimeSpan("akka.cluster.failure-detector.acceptable-heartbeat-pause"));
    }

    /// <summary>Verifies seed nodes and roles are still threaded through unchanged.</summary>
    [Fact]
    public void BuildClusterOptions_preserves_seed_nodes_and_roles()
    {
        var input = SampleOptions();

        var options = ServiceCollectionExtensions.BuildClusterOptions(input);

        options.SeedNodes.ShouldBe(input.SeedNodes);
        options.Roles.ShouldBe(input.Roles);
    }
}
