using Akka.Actor;
using Akka.Cluster.Hosting;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;

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

/// <summary>
/// Guards Phase 6's re-homing of <c>RedundancyStateActor</c> off the fleet-wide admin singleton onto
/// a per-node <c>cluster-{ClusterId}</c> singleton (see
/// <see cref="ServiceCollectionExtensions.WithOtOpcUaClusterRedundancySingleton"/>). After the mesh
/// split a driver-only site pair has NO admin node to elect its Primary, so the singleton must be
/// scoped to the node's own cluster role and spawned on every driver node — one per mesh.
///
/// <para>The role scope + the missing-role guard are asserted against the extracted
/// <see cref="ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions"/> helper directly —
/// the same pattern <c>SplitBrainResolverActivationTests</c> uses for <c>BuildDowningHocon</c> /
/// <c>BuildClusterOptions</c>: it pins the exact <see cref="ClusterSingletonOptions.Role"/> without
/// booting a cluster, which no builder-introspection API can otherwise reach. The admin-removal claim
/// is behavioral (a booted admin node's <see cref="ActorRegistry"/> must no longer carry the
/// redundancy key) so it needs a real host.</para>
/// </summary>
public sealed class RedundancyStateSingletonRehomeTests
{
    /// <summary>The singleton options scope to the node's OWN cluster role, not the fixed admin role.</summary>
    [Fact]
    public void Options_scope_the_singleton_to_the_nodes_cluster_role()
    {
        var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
            new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } });

        options.Role.ShouldBe("cluster-SITE-A");
    }

    /// <summary>
    /// A node with no <c>cluster-{ClusterId}</c> role (a legacy single-mesh node, or the
    /// <c>TwoNodeClusterHarness</c> with roles <c>admin,driver</c>) falls back to the fixed
    /// <c>driver</c> role rather than throwing — so legacy/not-yet-migrated deployments keep booting.
    /// On a split 2-node mesh the <c>driver</c> role is already pair-local (the mesh IS the pair).
    /// </summary>
    [Fact]
    public void Missing_cluster_role_falls_back_to_the_driver_role()
    {
        var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
            new AkkaClusterOptions { Roles = new[] { "admin", "driver" } });

        options.Role.ShouldBe(RoleParser.Driver);
    }

    /// <summary>
    /// The admin singleton set no longer registers <c>redundancy-state</c>: a booted admin node's
    /// registry carries the OTHER admin singletons (proxy) but NOT the redundancy key.
    /// </summary>
    [Fact]
    public async Task Admin_singletons_no_longer_register_the_redundancy_singleton()
    {
        var registry = await BootAndGetRegistryAsync(
            roles: new[] { "admin" },
            configure: ab => ab.WithOtOpcUaControlPlaneSingletons());

        registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeFalse(
            "the redundancy singleton was re-homed off the admin node onto every driver node (Phase 6)");
        registry.TryGet<FleetStatusBroadcasterKey>(out _).ShouldBeTrue(
            "the remaining admin singletons must still register");
    }

    /// <summary>
    /// The re-home target: a driver node carrying a <c>cluster-{ClusterId}</c> role registers the
    /// redundancy singleton (proxy) via the new extension.
    /// </summary>
    [Fact]
    public async Task Cluster_redundancy_extension_registers_the_singleton_on_a_driver_node()
    {
        var registry = await BootAndGetRegistryAsync(
            roles: new[] { "driver", "cluster-SITE-A" },
            configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
                new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } }));

        registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
            "every driver node hosts its own mesh's redundancy singleton (Phase 6)");
    }

    /// <summary>
    /// The driver-role FALLBACK end-to-end: a node carrying only <c>driver</c> (no cluster role — a
    /// legacy single-mesh node or the <c>TwoNodeClusterHarness</c>) still boots and registers the
    /// redundancy singleton via the new extension. Proves the fallback works at the host/registry
    /// level, not just in the pure helper — the boot that the earlier throw-based version would have
    /// aborted.
    /// </summary>
    [Fact]
    public async Task Driver_role_fallback_registers_the_singleton_end_to_end()
    {
        var registry = await BootAndGetRegistryAsync(
            roles: new[] { "driver" },
            configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
                new AkkaClusterOptions { Roles = new[] { "driver" } }));

        registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
            "a driver node with no cluster role falls back to the driver role and still boots + registers");
    }

    /// <summary>
    /// Boots a real host through the production cluster bootstrap (Port=0, no seeds, so it never forms
    /// a cluster and no singleton props factory runs — only the proxies register), applies the
    /// supplied singleton registration, and returns the <see cref="ActorRegistry"/> after teardown.
    /// Start/stop live here rather than the test bodies to keep the CancellationToken-less calls out
    /// of the way (mirrors <c>ClusterRoleInfoTests.ResolveAsync</c>).
    /// </summary>
    private static async Task<ActorRegistry> BootAndGetRegistryAsync(
        string[] roles, Action<AkkaConfigurationBuilder> configure)
    {
        var options = new AkkaClusterOptions
        {
            Port = 0,
            Hostname = "127.0.0.1",
            PublicHostname = "127.0.0.1",
            SeedNodes = Array.Empty<string>(),
            Roles = roles,
        };

        var builder = Host.CreateDefaultBuilder();
        builder.ConfigureServices(services =>
        {
            services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
            // The admin WithActors block reads IOptions<MeshTransportOptions>.Value at spawn time.
            services.AddSingleton<IOptions<MeshTransportOptions>>(Options.Create(new MeshTransportOptions()));
            // Akka.Hosting invokes every singleton's props factory eagerly at StartAsync; the admin
            // singletons (coordinator/audit/reconciler) read IDbContextFactory<OtOpcUaConfigDbContext>,
            // so supply an in-memory one or those factories NRE. The driver-only boots don't touch it.
            services.AddDbContextFactory<OtOpcUaConfigDbContext>(
                o => o.UseInMemoryDatabase($"rehome-test-{Guid.NewGuid():N}"));
            // The admin ClusterNodeAddressReconciler props factory resolves IClusterRoleInfo (for its
            // per-cluster reconcile scope). A null cluster role ⇒ full-fleet reconcile, which is all
            // this test needs — it never asserts on the reconciler, only on the singleton registry.
            services.AddSingleton<IClusterRoleInfo>(new FakeClusterRoleInfo(clusterRole: null, clusterId: null));
            services.AddAkka("otopcua-rehome-test", (ab, sp) =>
            {
                ab.WithOtOpcUaClusterBootstrap(sp);
                configure(ab);
            });
        });

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

    /// <summary>Minimal <see cref="IClusterRoleInfo"/> stub exposing only the cluster-role identity.</summary>
    private sealed class FakeClusterRoleInfo : IClusterRoleInfo
    {
        public FakeClusterRoleInfo(string? clusterRole, string? clusterId)
        {
            ClusterRole = clusterRole;
            ClusterId = clusterId;
        }

        public string? ClusterRole { get; }
        public string? ClusterId { get; }
        public NodeId LocalNode => NodeId.Parse("127.0.0.1:0");
        public IReadOnlySet<string> LocalRoles => new HashSet<string>();
        public bool HasRole(string role) => false;
        public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
        public NodeId? RoleLeader(string role) => null;
        public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged { add { } remove { } }
    }
}
