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

/// <summary>What kind of disagreement was found between a node's real address and its row.</summary>
public enum AddressMismatchKind
{
    /// <summary>
    ///     The row was matched to a live member by <c>NodeId</c>, but its <c>Host</c> / <c>AkkaPort</c>
    ///     columns name a different address. Phase 2 dials those columns, so central would dial
    ///     somewhere the node is not — and the symptom there is a silent absence of acks.
    /// </summary>
    RowDialTargetDisagrees,

    /// <summary>
    ///     A driver-role member is in the cluster with no enabled <c>ClusterNode</c> row at its
    ///     address. Its deployment acks are discarded (see <c>ConfigPublishCoordinator</c>), so it
    ///     silently receives deployments it is never credited for.
    /// </summary>
    RunningNodeHasNoRow,

    /// <summary>
    ///     An enabled row names a node that is not currently a driver-role member. Either it is down
    ///     — in which case the next deployment fails at the apply deadline — or it binds an address
    ///     other than the one its row claims.
    /// </summary>
    EnabledRowNotInCluster,
}

/// <summary>One disagreement, with enough detail to act on without opening the DB.</summary>
/// <param name="Kind">Which disagreement this is.</param>
/// <param name="NodeId">The <c>host:port</c> identity the finding is about.</param>
/// <param name="Detail">Human-readable specifics, including what to do about it.</param>
public sealed record ClusterNodeAddressMismatch(AddressMismatchKind Kind, string NodeId, string Detail);

/// <summary>A <c>ClusterNode</c> row reduced to the address facts this check cares about.</summary>
/// <param name="NodeId">The row's primary key, in the <c>host:port</c> space.</param>
/// <param name="Host">The host central will dial in Phase 2.</param>
/// <param name="AkkaPort">The remoting port central will dial in Phase 2.</param>
public sealed record ClusterNodeAddress(string NodeId, string Host, int AkkaPort);

/// <summary>
///     Compares the addresses driver nodes actually occupy against the <c>ClusterNode</c> rows that
///     claim to describe them.
/// </summary>
/// <remarks>
///     <para>
///         <b>Why this exists.</b> <c>ClusterNode.AkkaPort</c> and the node's own <c>Cluster:Port</c>
///         are the same fact stored in two places, and nothing makes them agree. A node that binds
///         4054 while its row says 4053 is unreachable from central once Phase 2 dials the row
///         instead of gossiping — and an unenforced duplicated address is the exact shape of the
///         <c>Modbus</c>/<c>ModbusTcp</c> and <c>TwinCat</c>/<c>Focas</c> drifts already recorded in
///         this repo. Since Phase 1 also made the rows the deploy path's expected-ack set, a drifted
///         row already costs a failed deployment today.
///     </para>
///     <para>
///         <b>It runs on an admin node</b>, comparing rows against the membership an admin node can
///         already see, rather than having each driver node assert its own row — Phase 4 removes the
///         driver nodes' ConfigDb connection entirely, so a self-assertion written there would have
///         to be deleted again.
///     </para>
///     <para>
///         <b>Scoped to the admin node's own cluster (Phase 6).</b> Once the fleet splits into one
///         mesh per cluster, an admin node no longer sees site members at all, and a fleet-wide sweep
///         would report every other cluster's row as
///         <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> forever. The actor therefore scopes
///         <b>both</b> inputs to this function to the admin node's own cluster: the <c>rows</c>/enabled
///         set to rows whose <c>ClusterId</c> matches
///         <see cref="ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IClusterRoleInfo.ClusterId"/>, and the
///         observed membership to members carrying its own
///         <see cref="ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IClusterRoleInfo.ClusterRole"/>
///         (<c>FilterOwnClusterDriverMembers</c>). Scoping only the rows would swap the
///         <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> false positive for a worse
///         Error-level <see cref="AddressMismatchKind.RunningNodeHasNoRow"/> during the mixed-mesh
///         window, where the DB scope is live but central still sees foreign members via gossip. A
///         legacy admin node carrying no cluster role (null <c>ClusterId</c>/<c>ClusterRole</c>) still
///         sees every member via gossip and reconciles the full fleet unchanged. The pure function
///         below is untouched — scoping happens entirely in the actor's DB queries and its
///         membership filter.
///     </para>
/// </remarks>
public static class ClusterNodeAddressReconciler
{
    /// <summary>
    ///     Reconciles observed driver-role member addresses against the configured rows.
    /// </summary>
    /// <param name="observedDriverMembers">
    ///     <c>host</c>/<c>port</c> of every Up member carrying the <c>driver</c> role. Admin-only
    ///     members must be excluded — they legitimately have no <c>ClusterNode</c> row, and including
    ///     them would report a mismatch for correct configuration.
    /// </param>
    /// <param name="rows">Every <c>ClusterNode</c> row, enabled or not.</param>
    /// <param name="expectedPresentNodeIds">
    ///     The subset of <paramref name="rows"/> that should currently be in the cluster: enabled and
    ///     not in maintenance. Excluded rows are deliberately still matched against membership (a
    ///     disabled row for a running node is worth knowing) but never reported as
    ///     <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> — being absent is the whole point
    ///     of disabling one or putting it in maintenance.
    /// </param>
    /// <returns>Every disagreement found, ordered by node id for stable logging.</returns>
    public static IReadOnlyList<ClusterNodeAddressMismatch> Reconcile(
        IReadOnlyCollection<(string Host, int Port)> observedDriverMembers,
        IReadOnlyCollection<ClusterNodeAddress> rows,
        IReadOnlyCollection<string> expectedPresentNodeIds)
    {
        var byNodeId = new Dictionary<string, ClusterNodeAddress>(StringComparer.OrdinalIgnoreCase);
        foreach (var row in rows) byNodeId[row.NodeId] = row;
        var expectedPresent = new HashSet<string>(expectedPresentNodeIds, StringComparer.OrdinalIgnoreCase);

        var findings = new List<ClusterNodeAddressMismatch>();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var (host, port) in observedDriverMembers)
        {
            var nodeId = $"{host}:{port}";
            seen.Add(nodeId);

            if (!byNodeId.TryGetValue(nodeId, out var row))
            {
                findings.Add(new ClusterNodeAddressMismatch(
                    AddressMismatchKind.RunningNodeHasNoRow, nodeId,
                    $"driver node is in the cluster at {nodeId} but no ClusterNode row has that NodeId; " +
                    "its deployment acks are discarded. Either register the row or correct the node's " +
                    "Cluster:PublicHostname / Cluster:Port to match an existing one"));
                continue;
            }

            if (!string.Equals(row.Host, host, StringComparison.OrdinalIgnoreCase) || row.AkkaPort != port)
            {
                findings.Add(new ClusterNodeAddressMismatch(
                    AddressMismatchKind.RowDialTargetDisagrees, nodeId,
                    $"node is at {nodeId} but its row's dial target is {row.Host}:{row.AkkaPort}; " +
                    "central will dial the wrong address once Phase 2 stops using gossip. " +
                    "Correct ClusterNode.Host / ClusterNode.AkkaPort"));
            }
        }

        foreach (var row in rows)
        {
            if (seen.Contains(row.NodeId) || !expectedPresent.Contains(row.NodeId)) continue;
            findings.Add(new ClusterNodeAddressMismatch(
                AddressMismatchKind.EnabledRowNotInCluster, row.NodeId,
                $"enabled ClusterNode row {row.NodeId} has no matching driver member; the next " +
                "deployment will fail at the apply deadline waiting for it. Start the node, or set " +
                "ClusterNode.MaintenanceMode = 1 while it is down for maintenance"));
        }

        return findings
            .OrderBy(f => f.NodeId, StringComparer.OrdinalIgnoreCase)
            .ThenBy(f => f.Kind)
            .ToList();
    }
}
