using System.Collections.Immutable;
using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Configuration;

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

/// <summary>
///     Central-side end of the central↔node command boundary (per-cluster mesh Phase 2). Routes
///     every central→node command out — over DistributedPubSub or ClusterClient, per
///     <see cref="MeshTransportOptions.Mode"/> — and forwards inbound <see cref="ApplyAck"/>s to the
///     deploy coordinator singleton.
/// </summary>
/// <remarks>
///     <para>
///         <b>Registered per node, NOT as a cluster singleton.</b> A driver node's ClusterClient
///         rotates across contact points and must find a live comm actor at whichever central node
///         answers. A singleton would be reachable only through the one node hosting it, and the
///         rotation would fail silently against the other.
///     </para>
///     <para>
///         <b>One ClusterClient per application <c>Cluster</c> (Phase 6).</b> The fleet is now one
///         Akka mesh per cluster, so a single fleet-wide client's <c>SendToAll</c> would reach only
///         the one mesh whose receptionist answered. This actor therefore holds a client per cluster
///         and fans <c>SendToAll</c> across all of them. See <see cref="RebuildClient"/> — this is the
///         single most important thing to understand before changing this actor.
///     </para>
/// </remarks>
public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
{
    private const string RefreshTimerKey = "mesh-contact-refresh";

    private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
    private readonly MeshTransportOptions _options;
    private readonly IMeshClusterClientFactory _clientFactory;
    private readonly Func<IActorRef?> _coordinator;
    private readonly ILoggingAdapter _log = Context.GetLogger();
    private readonly bool _useClusterClient;

    // One client per application Cluster, keyed by ClusterId. A ClusterClient reaches only the mesh
    // whose receptionist it dialled, and after Phase 6 every cluster is its own mesh — so central must
    // hold a client per cluster and fan SendToAll across all of them.
    private readonly Dictionary<string, (IActorRef Client, ImmutableHashSet<string> Contacts)> _clients =
        new();

    private CancellationTokenSource _lifecycle = new();

    /// <summary>Gets the timer scheduler driving the periodic contact refresh.</summary>
    public ITimerScheduler Timers { get; set; } = null!;

    /// <summary>Creates the props for this actor.</summary>
    /// <param name="dbFactory">Factory for the config database holding <c>ClusterNode</c> rows.</param>
    /// <param name="options">The bound mesh-transport options.</param>
    /// <param name="clientFactory">Creates the ClusterClient; substituted in tests.</param>
    /// <param name="coordinator">
    ///     Resolves the deploy-coordinator singleton proxy. A <see cref="Func{TResult}"/> rather than
    ///     an <see cref="IActorRef"/> so this actor never depends on registration order at wiring
    ///     time — it resolves on first use and caches.
    /// </param>
    /// <returns>The props.</returns>
    public static Props Props(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        MeshTransportOptions options,
        IMeshClusterClientFactory clientFactory,
        Func<IActorRef?> coordinator) =>
        Akka.Actor.Props.Create(() =>
            new CentralCommunicationActor(dbFactory, options, clientFactory, coordinator));

    /// <summary>Initializes a new instance of the <see cref="CentralCommunicationActor"/> class.</summary>
    /// <param name="dbFactory">Factory for the config database.</param>
    /// <param name="options">The bound mesh-transport options.</param>
    /// <param name="clientFactory">Creates the ClusterClient.</param>
    /// <param name="coordinator">Lazily resolves the deploy-coordinator singleton proxy.</param>
    public CentralCommunicationActor(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        MeshTransportOptions options,
        IMeshClusterClientFactory clientFactory,
        Func<IActorRef?> coordinator)
    {
        _dbFactory = dbFactory;
        _options = options;
        _clientFactory = clientFactory;
        _coordinator = coordinator;
        _useClusterClient = string.Equals(
            options.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase);

        Receive<MeshCommand>(HandleMeshCommand);
        Receive<ApplyAck>(HandleApplyAck);
        Receive<RefreshContacts>(_ => LoadContactsFromDb());
        Receive<ContactsLoaded>(HandleContactsLoaded);

        // A faulted LoadContactsFromDb task is piped here as a Status.Failure. Without this handler
        // the failure is an unhandled message (debug level only) and the refresh fails silently —
        // an operator cannot distinguish "no nodes configured" from "the database is down", and the
        // contact set silently freezes at whatever it last held.
        Receive<Status.Failure>(f => _log.Warning(
            f.Cause,
            "Failed to load ClusterNode contact points; the mesh ClusterClient contact set was NOT "
            + "refreshed and may be stale or empty. Commands may be dropped until the next refresh"));

        Receive<SubscribeAck>(_ => { /* DPS subscribe confirmation */ });
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        if (!_useClusterClient)
        {
            _log.Info(
                "Mesh transport is {Mode}: commands are published on DistributedPubSub and no "
                + "ClusterClient is created", _options.Mode);
            return;
        }

        Timers.StartPeriodicTimer(
            RefreshTimerKey,
            new RefreshContacts(),
            TimeSpan.Zero,
            TimeSpan.FromSeconds(_options.ContactRefreshSeconds));
    }

    /// <inheritdoc />
    protected override void PostStop()
    {
        foreach (var (client, _) in _clients.Values) Context.Stop(client);
        _clients.Clear();

        _lifecycle.Cancel();
        _lifecycle.Dispose();
    }

    private void HandleMeshCommand(MeshCommand cmd)
    {
        if (!_useClusterClient)
        {
            DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(cmd.Topic, cmd.Message));
            return;
        }

        if (_clients.Count == 0)
        {
            // App-level drop, matching the sister project's decision and this repo's buffer-size = 0.
            // The command is NOT queued and will NOT be retried: a deploy fails at the coordinator's
            // apply deadline naming the silent nodes, which is a better operator signal than a
            // command arriving minutes late against a config the DB already recorded as failed.
            _log.Warning(
                "No mesh ClusterClient — dropping {MessageType}. The last contact refresh produced "
                + "no usable contact point from the enabled, non-maintenance ClusterNode rows in any "
                + "cluster; the command is not buffered and will not be retried",
                cmd.Message.GetType().Name);
            return;
        }

        // SendToAll, NEVER Send, fanned across EVERY cluster's client. Today's DPS publish reaches
        // EVERY DriverHostActor — there is no ClusterId or node filter on the node side at all; scoping
        // happens later, inside the artifact (DeploymentArtifact.ResolveClusterScope). Each ClusterClient
        // reaches only the one mesh it dialled, so central must fan across all of them to preserve the
        // fleet-wide reach DPS gives. ClusterClient.Send delivers to exactly ONE registered actor, which
        // would deploy to a single node while every other node silently kept its old configuration — and
        // the deployment would still seal green if the ack set happened to be satisfied.
        foreach (var (client, _) in _clients.Values)
            client.Tell(new ClusterClient.SendToAll(MeshPaths.NodeCommunication, cmd.Message));
    }

    private void HandleApplyAck(ApplyAck ack)
    {
        var coordinator = _coordinator();
        if (coordinator is null)
        {
            _log.Warning(
                "Received ApplyAck for {DeploymentId} from {NodeId} but the deploy coordinator is "
                + "not resolvable on this node; the ack is lost and the deployment will time out "
                + "naming a node that applied successfully",
                ack.DeploymentId, ack.NodeId);
            return;
        }

        // Forward, not Tell: preserves the original sender across the hop so the coordinator sees
        // the node rather than this relay.
        coordinator.Forward(ack);
    }

    private void LoadContactsFromDb()
    {
        var self = Self;
        CancellationToken ct;
        try
        {
            ct = _lifecycle.Token;
        }
        catch (ObjectDisposedException)
        {
            return; // Stopping.
        }

        var systemName = Context.System.Name;
        var dbFactory = _dbFactory;

        // Off the actor thread, result piped back as a message. A synchronous DB read here would
        // block every command dispatch behind a slow or unreachable SQL Server.
        Task.Run(async () =>
        {
            await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
            var rows = await db.ClusterNodes
                .AsNoTracking()
                .Where(n => n.Enabled && !n.MaintenanceMode)
                .Select(n => new { n.NodeId, n.ClusterId, n.Host, n.AkkaPort })
                .ToListAsync(ct)
                .ConfigureAwait(false);

            // Grouped BY ClusterId: central now dials one ClusterClient per application Cluster, so the
            // contact set must be partitioned the same way. A row's contacts join only its own cluster's
            // client.
            var byCluster = new Dictionary<string, List<string>>();
            var malformed = new List<string>();
            foreach (var row in rows)
            {
                var address = $"akka.tcp://{systemName}@{row.Host}:{row.AkkaPort}/system/receptionist";

                // Parse up front, per row, inside the loop's own guard. A single malformed row must
                // not abort the whole refresh and leave the contact set half-built — the sister
                // project shipped that bug and its regression test deliberately orders the bad row
                // first.
                if (ActorPath.TryParse(address, out _))
                {
                    if (!byCluster.TryGetValue(row.ClusterId, out var list))
                        byCluster[row.ClusterId] = list = new List<string>();
                    list.Add(address);
                }
                else
                {
                    malformed.Add($"{row.NodeId} ({row.ClusterId}) -> {address}");
                }
            }

            var contactsByCluster = byCluster.ToDictionary(
                kv => kv.Key, kv => (IReadOnlyList<string>)kv.Value);
            return new ContactsLoaded(contactsByCluster, malformed);
        }, ct).PipeTo(self);
    }

    private void HandleContactsLoaded(ContactsLoaded msg)
    {
        foreach (var bad in msg.Malformed)
        {
            _log.Warning(
                "ClusterNode {Row} does not yield a usable receptionist address; skipping it in this "
                + "refresh (other nodes are unaffected). Check the row's Host and AkkaPort", bad);
        }

        var byCluster = msg.ContactsByCluster.ToDictionary(
            kv => kv.Key, kv => kv.Value.ToImmutableHashSet());

        // Clusters that dropped out of the DB entirely, or whose only rows were malformed this refresh:
        // stop and forget their clients so a vanished cluster stops receiving. LoadContactsFromDb only
        // creates a ByCluster key when at least one of its rows parsed, so an all-malformed cluster has
        // NO key at all and is caught here by the absent-key (!TryGetValue) arm — not by an empty value.
        var gone = _clients.Keys
            .Where(id => !byCluster.TryGetValue(id, out var c) || c.IsEmpty)
            .ToList();
        foreach (var clusterId in gone)
        {
            _log.Info(
                "Cluster {ClusterId} no longer yields any usable contact point; stopping its "
                + "ClusterClient", clusterId);
            Context.Stop(_clients[clusterId].Client);
            _clients.Remove(clusterId);
        }

        var anyUsable = false;
        foreach (var (clusterId, contacts) in byCluster)
        {
            if (contacts.IsEmpty)
            {
                // Defensive: LoadContactsFromDb never emits an empty-but-present cluster (a key is
                // created only on a first successfully-parsed row), so this arm is unreachable for the
                // DB producer — an all-malformed cluster is handled by the absent-key branch above. It
                // guards a ContactsLoaded (a public record) constructed elsewhere with an empty list.
                _log.Warning(
                    "Cluster {ClusterId} has no usable ClusterNode contact points; no ClusterClient "
                    + "was created for it and its nodes' commands will be dropped until a refresh finds "
                    + "one", clusterId);
                continue;
            }

            anyUsable = true;

            if (_clients.TryGetValue(clusterId, out var existing) && existing.Contacts.SetEquals(contacts))
                continue;

            RebuildClient(clusterId, contacts);
        }

        if (!anyUsable && _clients.Count == 0)
        {
            _log.Warning(
                "No usable ClusterNode contact points in any cluster; no mesh ClusterClient was "
                + "created and every central→node command will be dropped until a refresh finds one");
        }
    }

    /// <summary>Stops a cluster's existing client and creates a replacement for its new contact set.</summary>
    /// <remarks>
    ///     <para>
    ///         <b>One client per application <c>Cluster</c> (delivered in Phase 6).</b> This actor now
    ///         holds one <c>ClusterClient</c> per cluster and <see cref="HandleMeshCommand"/> fans
    ///         <c>SendToAll</c> across all of them. Once the fleet is split into one Akka mesh per
    ///         cluster, a <c>ClusterClientReceptionist</c> serves only <i>its own</i> mesh, so a single
    ///         client's <c>SendToAll</c> would reach the one mesh whose receptionist answered and leave
    ///         every other cluster silently on its old configuration. Fanning across a client per
    ///         cluster restores the fleet-wide reach the pre-split DPS broadcast gave.
    ///     </para>
    ///     <para>
    ///         Why this is not N× duplicate dispatch: each client dials only its own cluster's
    ///         receptionists, so a command is delivered <b>once per mesh</b>, not once per cluster into
    ///         every mesh. (While the fleet was a single mesh, the same code would have duplicated —
    ///         which is exactly why the fan-out could not land until the meshes were partitioned.)
    ///     </para>
    ///     <para>
    ///         Corollary worth stating because it is easy to assume otherwise: <b>the contact set
    ///         does not scope delivery within a mesh.</b> Excluding a maintenance-mode node from a
    ///         cluster's contacts does not stop it receiving commands — its receptionist still delivers
    ///         to every registered node-comm actor in that mesh. The filter is about which receptionists
    ///         are worth <i>dialling</i> (a node in maintenance may be switched off), not about who gets
    ///         the message.
    ///     </para>
    /// </remarks>
    /// <param name="clusterId">The application <c>Cluster</c> whose client is being (re)built.</param>
    /// <param name="contacts">The receptionist addresses to dial for that cluster.</param>
    private void RebuildClient(string clusterId, ImmutableHashSet<string> contacts)
    {
        if (_clients.TryGetValue(clusterId, out var existing))
        {
            _log.Info("Contact set for cluster {ClusterId} changed; replacing its ClusterClient", clusterId);
            Context.Stop(existing.Client);

            // Removed now: if the create below throws, a stale ref would route commands into a
            // stopping actor and they would vanish without a warning.
            _clients.Remove(clusterId);
        }

        try
        {
            var paths = contacts.Select(ActorPath.Parse).ToImmutableHashSet();
            var client = _clientFactory.Create(Context.System, clusterId, paths);
            _clients[clusterId] = (client, contacts);
            _log.Info(
                "Mesh ClusterClient for cluster {ClusterId} created with {Count} contact point(s)",
                clusterId, contacts.Count);
        }
        catch (Exception ex)
        {
            _log.Error(ex,
                "Failed to create the mesh ClusterClient for cluster {ClusterId}; its nodes' commands "
                + "are dropped until the next contact refresh succeeds", clusterId);
        }
    }

    /// <summary>Internal tick asking for a contact-point refresh.</summary>
    public sealed record RefreshContacts;

    /// <summary>Result of a contact-point load, piped back from the DB read.</summary>
    /// <param name="ContactsByCluster">
    ///     Receptionist addresses that parsed cleanly, grouped by the owning application
    ///     <c>Cluster</c>'s id. One <c>ClusterClient</c> is built per key.
    /// </param>
    /// <param name="Malformed">Rows that did not parse, described for the log.</param>
    public sealed record ContactsLoaded(
        IReadOnlyDictionary<string, IReadOnlyList<string>> ContactsByCluster,
        IReadOnlyList<string> Malformed);
}
