using System.Collections.Immutable;
using System.Reflection;
using Akka.Actor;
using Akka.Cluster;
using Akka.Util;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;

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

/// <summary>
///     Per-cluster mesh Phase 1 Task 4: <c>ClusterNode.AkkaPort</c> and the node's own
///     <c>Cluster:Port</c> are the same fact in two places, and nothing makes them agree. These pin
///     the shapes that drift takes.
/// </summary>
public sealed class ClusterNodeAddressReconcilerTests
{
    private static ClusterNodeAddress Row(string host, int akkaPort, string? nodeId = null) =>
        new(nodeId ?? $"{host}:{akkaPort}", host, akkaPort);

    /// <summary>A fleet whose rows match membership reports nothing.</summary>
    [Fact]
    public void Matching_rows_and_membership_produce_no_findings()
    {
        var findings = ClusterNodeAddressReconciler.Reconcile(
            [("site-a-1", 4053), ("site-a-2", 4053)],
            [Row("site-a-1", 4053), Row("site-a-2", 4053)],
            ["site-a-1:4053", "site-a-2:4053"]);

        findings.ShouldBeEmpty();
    }

    /// <summary>
    ///     The drift the plan names: a node binds 4054 while its row says 4053. Membership shows it
    ///     at an address no row claims, and the row it should have matched looks absent — both halves
    ///     are reported, because either alone reads as a different problem.
    /// </summary>
    [Fact]
    public void Node_bound_to_the_wrong_port_is_reported_from_both_sides()
    {
        var findings = ClusterNodeAddressReconciler.Reconcile(
            [("site-a-1", 4054)],
            [Row("site-a-1", 4053)],
            ["site-a-1:4053"]);

        findings.Count.ShouldBe(2);
        findings.ShouldContain(f =>
            f.Kind == AddressMismatchKind.RunningNodeHasNoRow && f.NodeId == "site-a-1:4054");
        findings.ShouldContain(f =>
            f.Kind == AddressMismatchKind.EnabledRowNotInCluster && f.NodeId == "site-a-1:4053");
    }

    /// <summary>
    ///     The Phase 2 shape: the row's NodeId still matches membership, but its <c>Host</c> /
    ///     <c>AkkaPort</c> columns — the values central will actually dial — have been edited away
    ///     from it. Gossip still works today, so nothing else would notice.
    /// </summary>
    [Fact]
    public void Row_whose_dial_target_disagrees_with_its_own_NodeId_is_reported()
    {
        var findings = ClusterNodeAddressReconciler.Reconcile(
            [("site-a-1", 4053)],
            [Row("site-a-1", 4054, nodeId: "site-a-1:4053")],
            ["site-a-1:4053"]);

        var only = findings.ShouldHaveSingleItem();
        only.Kind.ShouldBe(AddressMismatchKind.RowDialTargetDisagrees);
        only.NodeId.ShouldBe("site-a-1:4053");
        only.Detail.ShouldContain("site-a-1:4054");
    }

    /// <summary>A running driver node nobody registered — the coordinator discards its acks.</summary>
    [Fact]
    public void Running_node_with_no_row_is_reported()
    {
        var findings = ClusterNodeAddressReconciler.Reconcile(
            [("ghost-9", 4053)], [], []);

        findings.ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow);
    }

    /// <summary>
    ///     A disabled row for an absent node is silence, not a finding — being absent is the entire
    ///     point of disabling it. Without this the maintenance hatch would trade a failed deploy for
    ///     a permanent warning.
    /// </summary>
    [Fact]
    public void Disabled_row_for_an_absent_node_is_not_reported()
    {
        var findings = ClusterNodeAddressReconciler.Reconcile(
            [("site-a-1", 4053)],
            [Row("site-a-1", 4053), Row("site-a-2", 4053)],
            ["site-a-1:4053"]);

        findings.ShouldBeEmpty();
    }

    /// <summary>
    ///     An admin-only member has no <c>ClusterNode</c> row by design, so the caller filters
    ///     membership to driver-role members. Passing an unfiltered list would report correct
    ///     configuration as broken — pinned here because the filter lives at the call site.
    /// </summary>
    [Fact]
    public void Admin_only_members_are_the_callers_responsibility_to_exclude()
    {
        // What the actor passes: driver members only.
        ClusterNodeAddressReconciler.Reconcile(
            [("central-1", 4053)], [Row("central-1", 4053)], ["central-1:4053"])
            .ShouldBeEmpty();

        // What an unfiltered call would produce — the false positive this guards against.
        ClusterNodeAddressReconciler.Reconcile(
            [("central-1", 4053), ("admin-only-1", 4053)],
            [Row("central-1", 4053)],
            ["central-1:4053"])
            .ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow);
    }

    /// <summary>
    ///     Host matching is case-insensitive, matching the coordinator's <c>NodeIdComparer</c> — DNS
    ///     and SQL collation are both case-insensitive, so the same node surfaces with either casing.
    /// </summary>
    [Fact]
    public void Host_matching_is_case_insensitive()
    {
        ClusterNodeAddressReconciler.Reconcile(
            [("SITE-A-1", 4053)], [Row("site-a-1", 4053)], ["site-a-1:4053"])
            .ShouldBeEmpty();
    }

    private static int _memberUid;

    // Akka's Member.Create factories are internal, so reflect the Up-status overload
    // Create(UniqueAddress, int upNumber, MemberStatus, ImmutableHashSet<string> roles, AppVersion).
    private static readonly MethodInfo MemberCreate = typeof(Member).GetMethod(
        "Create",
        BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
        binder: null,
        types:
        [
            typeof(UniqueAddress), typeof(int), typeof(MemberStatus),
            typeof(ImmutableHashSet<string>), typeof(AppVersion),
        ],
        modifiers: null)
        ?? throw new InvalidOperationException("Akka.Cluster.Member.Create(5-arg) not found — Akka API changed");

    /// <summary>
    ///     Builds an Up cluster <see cref="Member"/> at <paramref name="host"/>:<paramref name="port"/>
    ///     carrying <paramref name="roles"/>, for exercising
    ///     <see cref="ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers"/> directly.
    /// </summary>
    private static Member UpMember(string host, int port, params string[] roles) =>
        (Member)MemberCreate.Invoke(null,
        [
            new UniqueAddress(new Address("akka.tcp", "otopcua", host, port), Interlocked.Increment(ref _memberUid)),
            1,
            MemberStatus.Up,
            roles.ToImmutableHashSet(),
            AppVersion.Zero,
        ])!;

    /// <summary>
    ///     Per-cluster mesh Phase 6: scoped to <c>cluster-MAIN</c>, a driver member carrying a FOREIGN
    ///     cluster role (<c>cluster-SITE-A</c>) — the mixed-mesh window, where central still sees it via
    ///     gossip while its row is already filtered out by ClusterId — is excluded from observed. Left
    ///     in, it would hit the Error-level <see cref="AddressMismatchKind.RunningNodeHasNoRow"/> branch.
    /// </summary>
    [Fact]
    public void Foreign_cluster_role_member_is_excluded_when_scoped()
    {
        var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
            [UpMember("site-a-1", 4053, "driver", "cluster-SITE-A")],
            ownClusterRole: "cluster-MAIN");

        observed.ShouldBeEmpty();
    }

    /// <summary>Scoped to <c>cluster-MAIN</c>, a member carrying that same role is kept.</summary>
    [Fact]
    public void Own_cluster_role_member_is_kept_when_scoped()
    {
        var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
            [UpMember("main-1", 4053, "driver", "cluster-MAIN")],
            ownClusterRole: "cluster-MAIN");

        observed.ShouldHaveSingleItem().ShouldBe(("main-1", 4053));
    }

    /// <summary>
    ///     Legacy: a null cluster role keeps every Up driver member regardless of cluster — an admin
    ///     node with no cluster role genuinely sees the whole fleet via gossip (reconcile-all).
    /// </summary>
    [Fact]
    public void Null_cluster_role_keeps_all_driver_members()
    {
        var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
            [
                UpMember("main-1", 4053, "driver", "cluster-MAIN"),
                UpMember("site-a-1", 4053, "driver", "cluster-SITE-A"),
            ],
            ownClusterRole: null);

        observed.Count.ShouldBe(2);
        observed.ShouldContain(("main-1", 4053));
        observed.ShouldContain(("site-a-1", 4053));
    }

    /// <summary>Non-driver members (e.g. admin-only) are excluded whether or not a scope is set.</summary>
    [Fact]
    public void Non_driver_members_are_always_excluded()
    {
        ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
            [UpMember("central-1", 4053, "admin", "cluster-MAIN")], ownClusterRole: "cluster-MAIN")
            .ShouldBeEmpty();
    }
}
