using Akka.Actor;
using Akka.Cluster;
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.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 (Task 31) can recover in-flight state from the DB.
///
/// Discovery of the "expected ACK set" comes from <c>Akka.Cluster.State.Members</c> filtered by
/// the <c>driver</c> role — the DB does not own per-node role assignment.
/// </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;
    private readonly ILoggingAdapter _log = Context.GetLogger();
    private readonly Dictionary<NodeId, ApplyAckOutcome> _acks = new();

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

    /// <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>
    public static Props Props(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        TimeSpan? applyDeadline = null) =>
        Akka.Actor.Props.Create(() => new ConfigPublishCoordinator(dbFactory, applyDeadline ?? DefaultApplyDeadline));

    /// <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>
    public ConfigPublishCoordinator(
        IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
        TimeSpan applyDeadline)
    {
        _dbFactory = dbFactory;
        _applyDeadline = applyDeadline;
        Receive<DispatchDeployment>(HandleDispatch);
        Receive<ApplyAck>(HandleAck);
        Receive<DeadlineElapsed>(HandleDeadline);
        Receive<SubscribeAck>(_ => { /* DPS subscribe confirmation */ });
    }

    /// <summary>
    /// On startup recover any deployment that was mid-flight when a prior singleton instance
    /// died. We re-derive <c>_expectedAcks</c> from <c>NodeDeploymentState</c>, replay the ACKs
    /// that already landed in the DB, and resume the deadline timer.
    /// </summary>
    /// <inheritdoc />
    protected override void PreStart()
    {
        // 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();
        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();
        _expectedAcks = DiscoverDriverNodes();

        // Seed NodeDeploymentState rows so a failover knows which nodes were expected to ack.
        using (var db = _dbFactory.CreateDbContext())
        {
            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();
        }

        DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(DeploymentsTopic, msg));
        Timers.StartSingleTimer(DeadlineTimerKey, new DeadlineElapsed(msg.DeploymentId), _applyDeadline);

        if (_expectedAcks.Count == 0)
        {
            // No driver-role members. Seal immediately — the alternative is hanging forever
            // waiting for ACKs that will never come.
            _log.Warning("DispatchDeployment {Id}: no driver-role members in cluster; sealing empty",
                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;
        }

        _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();
        _log.Warning("Deployment {Id} timed out after {Deadline} ({Acked}/{Total} acks landed)",
            _current.Value, _applyDeadline, _acks.Count, _expectedAcks.Count);
        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;
    }

    private HashSet<NodeId> DiscoverDriverNodes()
    {
        var cluster = Akka.Cluster.Cluster.Get(Context.System);
        var nodes = new HashSet<NodeId>();
        foreach (var member in cluster.State.Members)
        {
            if (member.Status is not (MemberStatus.Up or MemberStatus.Joining)) continue;
            if (!member.Roles.Contains("driver")) continue;
            var host = member.Address.Host;
            if (string.IsNullOrWhiteSpace(host)) continue;
            // Match ClusterRoleInfo's NodeId derivation (host:port) so DriverHostActor's
            // self-identification and the coordinator's expected-ack set agree.
            nodes.Add(NodeId.Parse($"{host}:{member.Address.Port ?? 0}"));
        }
        return nodes;
    }
}
