using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;

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

/// <summary>
///     Supervisor that gives Equipment-namespace VirtualTags live values. For each
///     <see cref="EquipmentVirtualTagPlan"/> in the desired set it spawns one child
///     <see cref="VirtualTagActor"/> (which self-registers with the dependency mux and evaluates its
///     expression on dependency changes) and remembers the plan's <b>folder-scoped NodeId</b>. When a
///     child reports a fresh <see cref="VirtualTagActor.EvaluationResult"/>, the host bridges it onto
///     an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> targeting that NodeId so the
///     already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
///
///     <para>
///         The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
///         slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
///         <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
///         always lands on a NodeId that exists.
///     </para>
/// </summary>
public sealed class VirtualTagHostActor : ReceiveActor
{
    /// <summary>Reconciles the live VirtualTag children to exactly the supplied desired set:
    /// stops children whose vtagId is gone, spawns children for new vtagIds, and rebuilds the
    /// vtagId→NodeId map so renames are reflected.</summary>
    /// <param name="Plans">The desired Equipment-namespace VirtualTag plans.</param>
    public sealed record ApplyVirtualTags(IReadOnlyList<EquipmentVirtualTagPlan> Plans);

    private readonly IActorRef _publishActor;
    private readonly IActorRef? _mux;
    private readonly IVirtualTagEvaluator _evaluator;
    // Sink for historized VirtualTag results (plans with Historize=true). NullHistoryWriter when no
    // durable historian is wired, so OnResult always has a non-null target.
    private readonly IHistoryWriter _history;
    private readonly ITelemetryLocalHub? _telemetryHub;
    private readonly ILoggingAdapter _log = Context.GetLogger();

    // vtagId -> spawned child VirtualTagActor.
    private readonly Dictionary<string, IActorRef> _children = new(StringComparer.Ordinal);
    // vtagId -> folder-scoped OPC UA NodeId the materialiser placed the variable at.
    private readonly Dictionary<string, string> _nodeIdByVtag = new(StringComparer.Ordinal);
    // vtagId -> the plan its live child was built from, so we can detect an in-place change (edited
    // Expression/DependencyRefs) and stop+respawn the child to adopt it. Kept in lock-step with
    // _children: rebuilt to the desired set at the end of every apply, and pruned in OnChildTerminated.
    private readonly Dictionary<string, EquipmentVirtualTagPlan> _planByVtag = new(StringComparer.Ordinal);

    /// <summary>Factory method to create Props for a VirtualTagHostActor.</summary>
    /// <param name="publishActor">The OPC UA publish actor that consumes
    /// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> bridged from child results.</param>
    /// <param name="mux">Optional dependency multiplexer; passed to each spawned child so it can
    /// register interest in its dependency refs. Null on the dev/Mac path (no live values).</param>
    /// <param name="evaluator">The evaluator each child uses to compute its expression.</param>
    /// <param name="historyWriter">Sink for results whose plan has <c>Historize=true</c>. Null ⇒
    /// <see cref="NullHistoryWriter.Instance"/> (no durable historian wired), so existing call sites
    /// compile unchanged and never historize.</param>
    /// <param name="telemetryHub">Optional node-local live-telemetry hub passed to each spawned child so
    /// its script-log entries also emit to the Phase-5 hub; null (tests) skips the emit.</param>
    /// <returns>The <see cref="Props"/> used to spawn a <see cref="VirtualTagHostActor"/>.</returns>
    public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator,
        IHistoryWriter? historyWriter = null, ITelemetryLocalHub? telemetryHub = null) =>
        Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator, historyWriter, telemetryHub));

    /// <summary>Initializes a new instance of the <see cref="VirtualTagHostActor"/> class.</summary>
    /// <param name="publishActor">The OPC UA publish actor results are bridged to.</param>
    /// <param name="mux">Optional dependency multiplexer passed to each spawned child.</param>
    /// <param name="evaluator">The evaluator each child uses to compute its expression.</param>
    /// <param name="historyWriter">Sink for historized results; null ⇒ <see cref="NullHistoryWriter.Instance"/>.</param>
    /// <param name="telemetryHub">Optional node-local live-telemetry hub passed to each spawned child so
    /// its script-log entries also emit to the Phase-5 hub; null (tests) skips the emit.</param>
    public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator,
        IHistoryWriter? historyWriter = null, ITelemetryLocalHub? telemetryHub = null)
    {
        ArgumentNullException.ThrowIfNull(publishActor);
        ArgumentNullException.ThrowIfNull(evaluator);
        _publishActor = publishActor;
        _mux = mux;
        _evaluator = evaluator;
        _history = historyWriter ?? NullHistoryWriter.Instance;
        _telemetryHub = telemetryHub;

        Receive<ApplyVirtualTags>(OnApply);
        Receive<VirtualTagActor.EvaluationResult>(OnResult);
        Receive<Terminated>(OnChildTerminated);
    }

    private void OnApply(ApplyVirtualTags msg)
    {
        // Apply-boundary compile-cache drop (gated — 02/P7): the production evaluator roots one
        // collectible AssemblyLoadContext per compiled expression, so a changed script set must drop the
        // stale ALCs. But clearing on EVERY apply forces a full recompilation even on a byte-identical
        // redeploy (and each unnecessary clear is an unnecessary 02/S12 race window). Clear only when the
        // deployed EXPRESSION set actually changed. The cache is keyed by source only, so a rename /
        // FolderPath move / Historize toggle (which respawns the child but keeps the source) needs no
        // recompile; set semantics also mean two vtags sharing one expression keep it alive while either
        // remains. _planByVtag still holds the PREVIOUS generation here (it is rebuilt to the desired set
        // at the end of this method); on the first apply after (re)start it is empty, so the sets differ
        // and the clear fires (harmless — converges the singleton evaluator's cache to this generation).
        // NOTE: this gate reduces clear FREQUENCY only; the S12 retry-once in RoslynVirtualTagEvaluator —
        // not this gate — is the correctness fix for a clear racing an in-flight evaluation.
        var newExpressions = msg.Plans.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
        var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
        if (!newExpressions.SetEquals(prevExpressions))
        {
            (_evaluator as IScriptCacheOwner)?.ClearCompiledScripts();
        }

        var desired = new HashSet<string>(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);

        // Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
        // PostStop, which unregisters its interest from the mux.
        foreach (var vtagId in _children.Keys.Where(id => !desired.Contains(id)).ToList())
        {
            Context.Stop(_children[vtagId]);
            _children.Remove(vtagId);
        }

        // Stop + forget children whose plan changed in place (edited Expression/DependencyRefs, or a
        // toggled flag). EquipmentVirtualTagPlan has element-wise value equality, so an identical
        // redeploy diffs equal and is left untouched. Forgetting the child here lets the spawn-new
        // loop below recreate it from the new plan — children are auto-named (no explicit name), so a
        // stop+respawn cannot hit the "actor name not unique" collision.
        foreach (var p in msg.Plans)
        {
            if (_children.ContainsKey(p.VirtualTagId)
                && _planByVtag.TryGetValue(p.VirtualTagId, out var prev)
                && !prev.Equals(p))
            {
                Context.Stop(_children[p.VirtualTagId]); // PostStop unregisters the old mux interest.
                _children.Remove(p.VirtualTagId);
            }
        }

        // Rebuild the NodeId map every apply so renames (Name/FolderPath/EquipmentId changes) are
        // picked up. The map only contains currently-desired vtags, so a result for a removed vtag
        // finds no entry and is dropped.
        // NOTE: if a respawn (above) was triggered by a Name/FolderPath/EquipmentId change, the
        // stopping old child may still deliver ONE in-flight result keyed by the same vtagId, which
        // OnResult would publish to the NEW NodeId. This is a transient phantom that the new child's
        // next evaluation overwrites — acceptable (and strictly better than the pre-H1b behaviour,
        // where a renamed vtag was a silent no-op).
        _nodeIdByVtag.Clear();
        foreach (var p in msg.Plans)
        {
            _nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
        }

        // Spawn children for vtagIds that have no live child. This covers genuinely-new vtags AND any
        // vtag whose child was just forgotten above because its plan changed in place — that child is
        // recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children
        // whose plan was unchanged still have a live entry and are skipped, keeping their mux
        // subscriptions and last-value dedup state.
        // Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
        // value to re-assert and publishes its initial value on the first dependency arrival anyway.
        var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
        foreach (var p in msg.Plans)
        {
            if (_children.ContainsKey(p.VirtualTagId)) continue;

            // Auto-name the child: vtagIds can contain characters illegal in actor names, so let Akka
            // assign a safe unique name. The child self-registers with the mux in PreStart.
            var child = Context.ActorOf(VirtualTagActor.Props(
                virtualTagId: p.VirtualTagId,
                expression: p.Expression,
                evaluator: _evaluator,
                scriptId: p.VirtualTagId,
                publisherFactory: null,
                dependencyRefs: p.DependencyRefs,
                mux: _mux,
                telemetryHub: _telemetryHub));
            Context.Watch(child);
            _children[p.VirtualTagId] = child;
            newlySpawned.Add(p.VirtualTagId);
            _log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
        }

        // Refresh the plan map to exactly the desired set so the next apply can detect in-place
        // changes against what each live child was actually built from.
        _planByVtag.Clear();
        foreach (var p in msg.Plans)
        {
            _planByVtag[p.VirtualTagId] = p;
        }

        // Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a
        // deploy, and on a deploy DriverHostActor enqueues RebuildAddressSpace — which re-materialises each
        // VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded OpcUaPublishActor FIRST,
        // then sends ApplyVirtualTags to THIS host. A surviving child keeps its value-dedup state, so an
        // unchanged recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with
        // a static dependency). Telling each survivor to ReassertValue makes it re-publish its last state; that
        // re-publish reaches the publish actor via a multi-hop chain (host → child ReassertValue → child →
        // parent EvaluationResult → OnResult → publish actor), so its AttributeValueUpdate lands AFTER
        // RebuildAddressSpace in the publish actor's FIFO mailbox — the materialise runs before the write, and
        // the node recovers. (The ordering does NOT come from ApplyVirtualTags itself reaching the publish
        // actor — it doesn't; it goes to this host.) Fresh children are skipped (nothing to re-assert; their
        // first dependency arrival publishes normally).
        foreach (var (vtagId, child) in _children)
        {
            if (newlySpawned.Contains(vtagId)) continue;
            child.Tell(VirtualTagActor.ReassertValue.Instance);
        }

        _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
            desired.Count, _children.Count);
    }

    private void OnResult(VirtualTagActor.EvaluationResult result)
    {
        // A result may arrive for a vtag that was just removed from the desired set (the child's
        // last in-flight message). With no NodeId mapping we have nowhere to land it — drop silently.
        if (!_nodeIdByVtag.TryGetValue(result.VirtualTagId, out var nodeId))
        {
            return;
        }

        // 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
        // failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
        // sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
        // VirtualTags materialise as equipment nodes in the UNS realm — publish there.
        _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
            nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));

        // Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
        // stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
        // node; recording it would append an artificial historian sample that never corresponded to a real
        // evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
        // _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
        // SAME folder-scoped NodeId we just published to. For a computed value source == server, so both
        // timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the
        // dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded
        // sample from a Good one.
        if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
        {
            var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
            _history.Record(nodeId, new DataValueSnapshot(
                result.Value, status, result.TimestampUtc, result.TimestampUtc));
        }
    }

    private void OnChildTerminated(Terminated msg)
    {
        var stale = _children.Where(kv => kv.Value.Equals(msg.ActorRef)).Select(kv => kv.Key).ToList();
        foreach (var id in stale)
        {
            _children.Remove(id);
            // Keep _planByVtag in lock-step with _children so it never holds a plan for a dead child.
            // (The next apply also resets it, and the change-detect guard short-circuits on the missing
            // _children entry — so this is defensive consistency, not a live-bug fix.) NodeId map is
            // rebuilt on the next ApplyVirtualTags; leaving that mapping is harmless. Dead child respawns.
            _planByVtag.Remove(id);
            _log.Warning("VirtualTagHost: child for vtag {VirtualTagId} terminated; will respawn on next apply", id);
        }
    }

    /// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
    /// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
    /// <see cref="V3NodeIds.Uns(string[])"/>. Byte-identical to the applier's equipment-VirtualTag materialise
    /// pass (its private <c>UnsEquipmentVar</c>), so the published value always lands on the materialised
    /// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments.</summary>
    private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
        string.IsNullOrWhiteSpace(p.FolderPath)
            ? V3NodeIds.Uns(p.EquipmentId, p.Name)
            : V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name));
}
