using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

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

/// <summary>
/// Admin-role cluster singleton that drives a deployment through its lifecycle: dispatches a
/// <see cref="DispatchDeployment"/> over DistributedPubSub on the <c>deployments</c> topic, gathers
/// per-node <see cref="ApplyAck"/> replies, and seals the deployment when every expected node
/// has acked Applied. Per-node ACKs are persisted in <c>NodeDeploymentState</c> so a failover of
/// this singleton can recover in-flight state from the DB.
///
/// Discovery of the "expected ACK set" comes from the <b>enabled <c>ClusterNode</c> rows</b>, not
/// from <c>Akka.Cluster.State.Members</c> (per-cluster mesh Phase 1). Central must be able to name
/// the nodes a deployment is for without sharing a gossip ring with them — Phase 2 splits the fleet
/// into one mesh per <c>Cluster</c>, after which central can no longer see a site node's membership
/// at all. This was the coordinator's last genuinely mesh-bound dependency.
///
/// <para>
/// <b>Behaviour change:</b> a configured node that is switched off is now <i>expected</i>, so a
/// deployment dispatched while it is down fails at the apply deadline instead of sealing green
/// without it. That is deliberate — under the membership rule the operator was told the fleet was
/// deployed when it was not. <c>ClusterNode.MaintenanceMode = 1</c> is the escape hatch for a node
/// taken down for maintenance.
/// </para>
/// <para>
/// <b>Not <c>Enabled = 0</c>.</b> That was the intended hatch until the Phase 1 live gate found it
/// unusable: <c>DraftValidator.ValidateClusterTopology</c> requires the enabled-node count to equal
/// <c>ServerCluster.NodeCount</c>, so clearing <c>Enabled</c> on either node of a Warm/Hot pair
/// rejects the deployment outright — and every cluster in the target topology is a pair. The two
/// rules were independently reasonable and contradictory together, so the meanings were split:
/// <c>Enabled</c> = part of the declared topology, <c>MaintenanceMode</c> = do not expect it now.
/// </para>
/// <para>
/// <b>Every <c>ClusterNode</c> row is assumed to be a driver node.</b> The membership rule filtered
/// on the <c>driver</c> role; the DB has no per-node role column, and deliberately does not gain one
/// here (a second declaration of node roles would drift from <c>Cluster:Roles</c> in the node's own
/// appsettings). The assumption holds because the entity's whole shape — <c>Host</c>,
/// <c>OpcUaPort</c>, <c>ApplicationUri</c>, <c>ServiceLevelBase</c> — describes an OPC UA server
/// node. An admin-only node must not be given a <c>ClusterNode</c> row: it would be expected to ack
/// a deployment it has no <c>DriverHostActor</c> to apply, and every deploy would time out.
/// </para>
/// <para>
/// <c>ServerCluster.Enabled</c> is <b>not</b> consulted — nothing else in the codebase consults it,
/// so honouring it here would invent semantics. Disable the nodes, not the cluster row.
/// </para>
/// </summary>
public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
{
    public const string DeploymentsTopic = "deployments";
    public const string DeploymentAcksTopic = "deployment-acks";
    public static readonly TimeSpan DefaultApplyDeadline = TimeSpan.FromMinutes(2);

    private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
    private readonly TimeSpan _applyDeadline;

    /// <summary>
    ///     Where central→node commands are handed off (per-cluster mesh Phase 2), or
    ///     <see langword="null"/> to publish on the mediator directly.
    /// </summary>
    /// <remarks>
    ///     Null in tests and on any node wired before Phase 2, where the fallback reproduces the
    ///     pre-Phase-2 behaviour exactly. The fallback is not a permanent seam — Phase 6 deletes it
    ///     along with the DistributedPubSub branch and the single mesh.
    /// </remarks>
    private readonly IActorRef? _meshRouter;
    private readonly ILoggingAdapter _log = Context.GetLogger();
    // NodeId equality here is case-insensitive (by Value) to match the case-insensitive ClusterId/
    // NodeId scoping in DeploymentArtifact.ResolveClusterScope — so an ack from a node whose address
    // differs only in case still matches its expected-ack entry (SQL collation + DNS are
    // case-insensitive, so the same node can surface with different casing).
    private static readonly IEqualityComparer<NodeId> NodeIdComparer = new CaseInsensitiveNodeIdComparer();
    private readonly Dictionary<NodeId, ApplyAckOutcome> _acks = new(NodeIdComparer);

    private DeploymentId? _current;
    private HashSet<NodeId> _expectedAcks = new(NodeIdComparer);

    /// <summary>Gets the timer scheduler for managing apply deadlines.</summary>
    public ITimerScheduler Timers { get; set; } = null!;

    /// <summary>Creates actor props for the ConfigPublishCoordinator with the specified configuration.</summary>
    /// <param name="dbFactory">The database context factory for accessing configuration data.</param>
    /// <param name="applyDeadline">The timeout for waiting for apply acknowledgments from cluster nodes.</param>
    /// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
    /// <param name="meshRouter">
    ///     Central comm actor the dispatch is handed to, or <see langword="null"/> to publish on the
    ///     DistributedPubSub mediator directly (the pre-Phase-2 behaviour).
    /// </param>
    public static Props Props(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        TimeSpan? applyDeadline = null,
        IActorRef? meshRouter = null) =>
        Akka.Actor.Props.Create(() =>
            new ConfigPublishCoordinator(dbFactory, applyDeadline ?? DefaultApplyDeadline, meshRouter));

    /// <summary>
    ///     Initializes a new instance of the <see cref="ConfigPublishCoordinator"/> class.
    /// </summary>
    /// <param name="dbFactory">The database context factory for persisting deployment state.</param>
    /// <param name="applyDeadline">The timeout for waiting for per-node apply acknowledgments.</param>
    /// <param name="meshRouter">Central comm actor, or <see langword="null"/> for the mediator.</param>
    public ConfigPublishCoordinator(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        TimeSpan applyDeadline,
        IActorRef? meshRouter = null)
    {
        _dbFactory = dbFactory;
        _applyDeadline = applyDeadline;
        _meshRouter = meshRouter;
        Receive<DispatchDeployment>(HandleDispatch);
        Receive<ApplyAck>(HandleAck);
        Receive<DeadlineElapsed>(HandleDeadline);
        Receive<SubscribeAck>(_ => { /* DPS subscribe confirmation */ });
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        // On startup recover any deployment that was mid-flight when a prior singleton instance
        // died. We re-derive _expectedAcks from NodeDeploymentState, replay the ACKs
        // that already landed in the DB, and resume the deadline timer.
        // Subscribe to per-node ApplyAck broadcasts so DriverHostActors on remote members can
        // route their ACKs to whichever node currently hosts this singleton.
        DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentAcksTopic, Self));

        using var db = _dbFactory.CreateDbContext();
        var inflight = db.Deployments
            .Where(d => d.Status == DeploymentStatus.Dispatching || d.Status == DeploymentStatus.AwaitingApplyAcks)
            .OrderByDescending(d => d.CreatedAtUtc)
            .FirstOrDefault();
        if (inflight is null) return;

        _current = new DeploymentId(inflight.DeploymentId);
        var nodeStates = db.NodeDeploymentStates
            .Where(x => x.DeploymentId == inflight.DeploymentId)
            .AsNoTracking()
            .ToList();

        _expectedAcks = nodeStates.Select(s => NodeId.Parse(s.NodeId)).ToHashSet(NodeIdComparer);
        foreach (var s in nodeStates.Where(s => s.Status != NodeDeploymentStatus.Applying))
            _acks[NodeId.Parse(s.NodeId)] = s.Status == NodeDeploymentStatus.Applied
                ? ApplyAckOutcome.Applied
                : ApplyAckOutcome.Failed;

        // Resume the deadline timer using the remaining time. The deadline runs from when the
        // deployment was first marked AwaitingApplyAcks (Deployment.CreatedAtUtc is a close enough
        // proxy — we don't track a separate "dispatched at" column).
        var elapsed = DateTime.UtcNow - inflight.CreatedAtUtc;
        var remaining = _applyDeadline - elapsed;
        if (remaining <= TimeSpan.Zero)
        {
            Self.Tell(new DeadlineElapsed(_current.Value));
        }
        else
        {
            Timers.StartSingleTimer(DeadlineTimerKey, new DeadlineElapsed(_current.Value), remaining);
        }
        _log.Info("Coordinator recovered in-flight deployment {Id} ({Acked}/{Total} acks landed)",
            _current, _acks.Count, _expectedAcks.Count);
    }

    private void HandleDispatch(DispatchDeployment msg)
    {
        _current = msg.DeploymentId;
        _acks.Clear();

        // Seed NodeDeploymentState rows so a failover knows which nodes were expected to ack.
        using (var db = _dbFactory.CreateDbContext())
        {
            // Discovery reuses this context rather than opening a second one — it reads the same
            // ClusterNode table the NodeDeploymentState rows below are FK-bound to, so a single
            // context keeps the expected set and the seeded rows consistent by construction.
            _expectedAcks = DiscoverDriverNodes(db);

            foreach (var node in _expectedAcks)
            {
                db.NodeDeploymentStates.Add(new NodeDeploymentState
                {
                    NodeId = node.Value,
                    DeploymentId = msg.DeploymentId.Value,
                    Status = NodeDeploymentStatus.Applying,
                });
            }
            UpdateDeploymentStatus(db, msg.DeploymentId, DeploymentStatus.AwaitingApplyAcks);
            db.SaveChanges();
        }

        // Per-cluster mesh Phase 2: hand off to the transport router rather than publishing
        // directly, so this actor no longer knows which transport is in force.
        if (_meshRouter is not null) _meshRouter.Tell(new MeshCommand(DeploymentsTopic, msg));
        else DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(DeploymentsTopic, msg));
        Timers.StartSingleTimer(DeadlineTimerKey, new DeadlineElapsed(msg.DeploymentId), _applyDeadline);

        if (_expectedAcks.Count == 0)
        {
            // No enabled ClusterNode rows. Seal immediately — the alternative is hanging forever
            // waiting for ACKs that will never come. Note this is now a *configuration* error
            // rather than a topology observation: under the old membership rule an empty set meant
            // "no driver nodes have joined yet", which could resolve itself; an empty set here means
            // the fleet has no enabled nodes registered, which cannot.
            _log.Warning(
                "DispatchDeployment {Id}: no enabled ClusterNode rows registered; sealing empty. " +
                "Nothing will receive this deployment — check the fleet's node configuration",
                msg.DeploymentId);
            SealDeployment();
        }
    }

    private void HandleAck(ApplyAck msg)
    {
        if (_current is null || msg.DeploymentId != _current.Value)
        {
            _log.Debug("Discarding stale ApplyAck for {Id} (current={Current})",
                msg.DeploymentId, _current);
            return;
        }

        // Discard acks from nodes that were not in the expected-ack set at dispatch time.
        // Without this guard an unexpected-node ack inflates _acks.Count and can trigger a
        // premature seal/fail before every expected node has actually applied the deployment.
        if (!_expectedAcks.Contains(msg.NodeId))
        {
            _log.Debug("Discarding ApplyAck from unexpected node {Node} for {Id} (not in expected set)",
                msg.NodeId, msg.DeploymentId);
            return;
        }

        _acks[msg.NodeId] = msg.Outcome;
        PersistNodeAck(msg);

        if (_acks.Count < _expectedAcks.Count) return;

        if (_acks.Values.All(o => o == ApplyAckOutcome.Applied))
            SealDeployment();
        else
            MarkPartiallyFailed();
    }

    private void PersistNodeAck(ApplyAck msg)
    {
        using var db = _dbFactory.CreateDbContext();
        var row = db.NodeDeploymentStates
            .FirstOrDefault(x => x.NodeId == msg.NodeId.Value && x.DeploymentId == msg.DeploymentId.Value);
        if (row is null) return;

        row.Status = msg.Outcome == ApplyAckOutcome.Applied
            ? NodeDeploymentStatus.Applied
            : NodeDeploymentStatus.Failed;
        row.AppliedAtUtc = DateTime.UtcNow;
        row.FailureReason = msg.FailureReason;
        db.SaveChanges();
    }

    private void SealDeployment()
    {
        if (_current is null) return;
        using var db = _dbFactory.CreateDbContext();
        UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.Sealed, sealNow: true);
        db.SaveChanges();
        _log.Info("Deployment {Id} sealed (acks={Count})", _current.Value, _acks.Count);
        ResetForNext();
    }

    private void MarkPartiallyFailed()
    {
        if (_current is null) return;
        using var db = _dbFactory.CreateDbContext();
        UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.PartiallyFailed);
        db.SaveChanges();
        _log.Warning("Deployment {Id} partially failed; acks={Acks}", _current.Value,
            string.Join(",", _acks.Select(kv => $"{kv.Key.Value}={kv.Value}")));
        ResetForNext();
    }

    private void HandleDeadline(DeadlineElapsed msg)
    {
        if (_current is null || msg.DeploymentId != _current.Value)
        {
            _log.Debug("Discarding stale DeadlineElapsed for {Id} (current={Current})",
                msg.DeploymentId, _current);
            return;
        }
        if (_acks.Count == _expectedAcks.Count)
        {
            // Race: every node acked just as the deadline fired. Already sealed/failed elsewhere.
            return;
        }

        using var db = _dbFactory.CreateDbContext();
        UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.TimedOut);
        db.SaveChanges();
        // Name the silent nodes, not just the counts. Since the expected set became DB-derived this
        // is the failure an operator meets after deploying while a configured node is switched off,
        // so it has to say which node — "4/5 acks landed" leaves them reading logs on five machines.
        var missing = _expectedAcks.Where(n => !_acks.ContainsKey(n)).Select(n => n.Value).Order().ToList();
        _log.Warning(
            "Deployment {Id} timed out after {Deadline} ({Acked}/{Total} acks landed). " +
            "No ack from: {MissingNodes}. Each is an enabled, non-maintenance ClusterNode row — start " +
            "the node, or set ClusterNode.MaintenanceMode = 1 if it is down for maintenance, then redeploy",
            _current.Value, _applyDeadline, _acks.Count, _expectedAcks.Count, string.Join(", ", missing));
        ResetForNext();
    }

    private void ResetForNext()
    {
        Timers.Cancel(DeadlineTimerKey);
        _current = null;
        _expectedAcks.Clear();
        _acks.Clear();
    }

    private const string DeadlineTimerKey = "apply-deadline";

    public sealed record DeadlineElapsed(DeploymentId DeploymentId);

    private static void UpdateDeploymentStatus(
        OtOpcUaConfigDbContext db, DeploymentId id, DeploymentStatus status, bool sealNow = false)
    {
        var d = db.Deployments.FirstOrDefault(x => x.DeploymentId == id.Value);
        if (d is null) return;
        d.Status = status;
        if (sealNow) d.SealedAtUtc = DateTime.UtcNow;
    }

    /// <summary>
    ///     The set of nodes expected to ack this deployment: every <b>enabled</b> <c>ClusterNode</c>
    ///     row. See the class remarks for why this is DB-derived rather than membership-derived, and
    ///     for the two assumptions it rests on (every row is a driver node; <c>Enabled</c> is the
    ///     maintenance hatch).
    /// </summary>
    /// <remarks>
    ///     <c>ClusterNode.NodeId</c> is already in the <c>host:port</c> identifier space the
    ///     membership rule derived — the rig seeds <c>central-1:4053</c> and so on, and
    ///     <c>NodeDeploymentState.NodeId</c> is FK-bound to this column, so the fact that the
    ///     membership-derived rows have always satisfied that FK is standing proof the two sets
    ///     agree. It also has to stay that way: <c>DriverHostActor</c> self-identifies from
    ///     <c>ClusterRoleInfo</c>'s <c>host:port</c>, so a row whose NodeId is anything else is a
    ///     node whose acks will never match its expected-ack entry.
    /// </remarks>
    private static HashSet<NodeId> DiscoverDriverNodes(OtOpcUaConfigDbContext db) =>
        db.ClusterNodes
            .AsNoTracking()
            .Where(n => n.Enabled && !n.MaintenanceMode)
            .Select(n => n.NodeId)
            .ToList()
            .Select(NodeId.Parse)
            .ToHashSet(NodeIdComparer);

    /// <summary>Case-insensitive <see cref="NodeId"/> equality (by <see cref="NodeId.Value"/>),
    /// matching the case-insensitive scoping in <c>DeploymentArtifact.ResolveClusterScope</c> so the
    /// expected-ack set and incoming acks agree regardless of host-name casing.</summary>
    private sealed class CaseInsensitiveNodeIdComparer : IEqualityComparer<NodeId>
    {
        /// <inheritdoc />
        public bool Equals(NodeId x, NodeId y) =>
            string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase);

        /// <inheritdoc />
        public int GetHashCode(NodeId obj) =>
            StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value ?? string.Empty);
    }
}
