using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;

namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;

public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged }

/// <summary>
///     One scripted alarm. Receives dependency value updates, runs the predicate via an
///     injected <see cref="IScriptedAlarmEvaluator"/>, and on transitions publishes both
///     an <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> DPS topic and a
///     <see cref="ScriptLogEntry"/> on <c>script-logs</c>. Manual <see cref="AcknowledgeAlarm"/>
///     + <see cref="ConditionCleared"/> still flow through the same state machine so the
///     legacy callers keep working.
/// </summary>
public sealed class ScriptedAlarmActor : ReceiveActor
{
    public const string AlertsTopic = "alerts";
    public const string ScriptLogsTopic = "script-logs";

    public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
    public sealed record ConditionMet(string Reason);
    public sealed record AcknowledgeAlarm(string Actor);
    public sealed record ConditionCleared;
    public sealed record StateChanged(string AlarmId, ScriptedAlarmActorState State, DateTime AtUtc);

    public sealed record AlarmConfig(
        string AlarmId,
        string AlarmName,
        string EquipmentPath,
        int Severity,
        string? Predicate);

    private readonly AlarmConfig _config;
    private readonly IScriptedAlarmEvaluator _evaluator;
    private readonly IAlarmActorStateStore _stateStore;
    private readonly Func<DPSPublisher>? _publisherFactory;
    private readonly ILoggingAdapter _log = Context.GetLogger();
    private readonly Dictionary<string, object?> _dependencies = new(StringComparer.Ordinal);

    private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive;
    private string? _lastAckUser;

    public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);

    /// <summary>Creates a new Props for a ScriptedAlarmActor with the given configuration and optional dependencies.</summary>
    /// <param name="config">The alarm configuration.</param>
    /// <param name="evaluator">The alarm evaluator; defaults to null evaluator if not provided.</param>
    /// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
    /// <param name="stateStore">Optional state store for persistence; defaults to null store if not provided.</param>
    /// <returns>Akka Props for creating the actor.</returns>
    public static Props Props(
        AlarmConfig config,
        IScriptedAlarmEvaluator? evaluator = null,
        Func<DPSPublisher>? publisherFactory = null,
        IAlarmActorStateStore? stateStore = null) =>
        Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
            config,
            evaluator ?? NullScriptedAlarmEvaluator.Instance,
            publisherFactory,
            stateStore ?? NullAlarmActorStateStore.Instance));

    /// <summary>Legacy single-arg ctor kept for callers that only care about the state machine
    /// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to <c>Props(new AlarmConfig(...))</c>.</summary>
    /// <param name="alarmId">The alarm identifier, used as both alarm ID and name.</param>
    /// <returns>Akka Props for creating the actor with minimal configuration.</returns>
    public static Props Props(string alarmId) =>
        Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));

    /// <summary>Initializes a new ScriptedAlarmActor with the given configuration and dependencies.</summary>
    /// <param name="config">The alarm configuration.</param>
    /// <param name="evaluator">The alarm predicate evaluator.</param>
    /// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
    /// <param name="stateStore">The state store for loading and saving alarm state.</param>
    public ScriptedAlarmActor(
        AlarmConfig config,
        IScriptedAlarmEvaluator evaluator,
        Func<DPSPublisher>? publisherFactory,
        IAlarmActorStateStore stateStore)
    {
        _config = config;
        _evaluator = evaluator;
        _publisherFactory = publisherFactory;
        _stateStore = stateStore;

        Receive<DependencyValueChanged>(OnDependencyChanged);
        Receive<ConditionMet>(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); });
        Receive<AcknowledgeAlarm>(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); });
        Receive<ConditionCleared>(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); });
        Receive<StateRestored>(OnStateRestored);
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        // Load persisted state — when the store has a row, restore in-memory state before the
        // first dependency-change arrives. Async I/O is piped back as StateRestored so we don't
        // block the message-loop thread; until it arrives the actor stays at the default Inactive.
        var self = Self;
        _ = Task.Run(async () =>
        {
            try
            {
                var snapshot = await _stateStore.LoadAsync(_config.AlarmId, CancellationToken.None)
                    .ConfigureAwait(false);
                if (snapshot is null) return;
                if (!Enum.TryParse<ScriptedAlarmActorState>(snapshot.State, ignoreCase: true, out var parsed))
                    return;
                self.Tell(new StateRestored(parsed, snapshot.LastAckUser));
            }
            catch (Exception ex)
            {
                _log.Warning(ex, "ScriptedAlarm {Id}: state-store load failed; booting Inactive",
                    _config.AlarmId);
            }
        });
    }

    private void OnStateRestored(StateRestored msg)
    {
        // Active is re-derived from the evaluator at the next DependencyValueChanged — we still
        // restore Active here so operators don't lose the in-flight transition if a restart races
        // ahead of the next eval. The first evaluator tick will correct it if the condition cleared.
        _state = msg.State;
        _lastAckUser = msg.LastAckUser;
        _log.Info("ScriptedAlarm {Id}: restored persisted state {State} (lastAck={User})",
            _config.AlarmId, _state, _lastAckUser ?? "(none)");
    }

    private void OnDependencyChanged(DependencyValueChanged msg)
    {
        _dependencies[msg.TagId] = msg.Value;

        if (string.IsNullOrEmpty(_config.Predicate)) return;

        ScriptedAlarmEvalResult result;
        try
        {
            result = _evaluator.Evaluate(_config.AlarmId, _config.Predicate, _dependencies);
        }
        catch (Exception ex)
        {
            _log.Warning(ex, "ScriptedAlarm {Id}: evaluator threw", _config.AlarmId);
            PublishLog("Error", $"evaluator threw: {ex.Message}");
            return;
        }

        if (!result.Success)
        {
            PublishLog("Warning", result.Reason ?? "evaluator failure");
            return;
        }

        // Active condition wins regardless of ack state — re-firing is suppressed because
        // _state already == Active. Cleared moves Active OR Acknowledged → Inactive.
        if (result.Active && _state == ScriptedAlarmActorState.Inactive)
        {
            Transition(ScriptedAlarmActorState.Active, user: "system");
        }
        else if (!result.Active && _state != ScriptedAlarmActorState.Inactive)
        {
            Transition(ScriptedAlarmActorState.Inactive, user: "system");
        }
    }

    private void Transition(ScriptedAlarmActorState next, string user)
    {
        var prev = _state;
        _state = next;
        if (next == ScriptedAlarmActorState.Acknowledged) _lastAckUser = user;
        _log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next);

        var nowUtc = DateTime.UtcNow;
        Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc));
        PersistStateAsync(nowUtc);

        var kind = next switch
        {
            ScriptedAlarmActorState.Active => "Activated",
            ScriptedAlarmActorState.Acknowledged => "Acknowledged",
            ScriptedAlarmActorState.Inactive => "Cleared",
            _ => next.ToString(),
        };

        OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
            new KeyValuePair<string, object?>("state", kind.ToLowerInvariant()));

        var evt = new AlarmTransitionEvent(
            AlarmId: _config.AlarmId,
            EquipmentPath: _config.EquipmentPath,
            AlarmName: _config.AlarmName,
            TransitionKind: kind,
            Severity: _config.Severity,
            Message: $"{_config.AlarmName} {kind}",
            User: user,
            TimestampUtc: nowUtc);

        PublishOrFallback(AlertsTopic, evt);
        PublishLog("Information", $"{_config.AlarmName} {kind} (by {user})");
    }

    private void PublishLog(string level, string message)
    {
        var entry = new ScriptLogEntry(
            ScriptId: _config.AlarmId,
            Level: level,
            Message: message,
            TimestampUtc: DateTime.UtcNow,
            VirtualTagId: null,
            AlarmId: _config.AlarmId,
            EquipmentId: null);
        PublishOrFallback(ScriptLogsTopic, entry);
    }

    private void PublishOrFallback(string topic, object payload)
    {
        if (_publisherFactory is not null)
        {
            _publisherFactory().Publish(topic, payload);
            return;
        }
        DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(topic, payload));
    }

    private void PersistStateAsync(DateTime nowUtc)
    {
        var snapshot = new AlarmActorStateSnapshot(
            AlarmId: _config.AlarmId,
            State: _state.ToString(),
            LastTransitionUtc: nowUtc,
            LastAckUser: _lastAckUser);

        // Fire-and-forget. Save failures get logged but don't block the message loop —
        // the worst case is a restart loses one transition, which then re-derives from
        // the evaluator's next tick anyway.
        _ = Task.Run(async () =>
        {
            try
            {
                await _stateStore.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _log.Warning(ex, "ScriptedAlarm {Id}: state-store save failed", _config.AlarmId);
            }
        });
    }
}
