using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;

/// <summary>
///     The <c>Calculation</c> pseudo-driver. Its tags are ordinary raw tags whose value is <b>computed</b>
///     by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
///     writes. It implements:
///     <list type="bullet">
///       <item><see cref="IDriver"/> — lifecycle + always-Connected health.</item>
///       <item><see cref="IDependencyConsumer"/> — the host feeds it the other tags' live values.</item>
///       <item><see cref="ISubscribable"/> — computed values flow out via <see cref="OnDataChange"/>
///       exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.</item>
///       <item><see cref="IReadable"/> — on-demand read returns the last computed snapshot
///       (<c>BadWaitingForInitialData</c> before the first evaluation).</item>
///     </list>
///
///     <para>
///         <b>Triggers.</b> A tag re-evaluates on any of its declared dependencies changing (change-trigger,
///         gated exactly like <c>VirtualTagActor</c>: nothing publishes until every declared dep has arrived
///         at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
///     </para>
///     <para>
///         <b>Error semantics (mini-design §6).</b> An evaluator <c>Failure</c> publishes <b>Bad</b> quality
///         carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
///         and emits a <c>ScriptLogEntry</c> on the <c>script-logs</c> topic (via the injected
///         <see cref="ScriptRootLogger"/>'s <c>ScriptLogTopicSink</c>). Recovery to Good is force-published.
///         A historized Bad records <c>BadInternalError</c>.
///     </para>
/// </summary>
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, IDisposable
{
    // OPC UA status codes surfaced by the calc driver.
    private const uint StatusGood = 0u;
    private const uint StatusBadWaitingForInitialData = 0x80320000u;
    private const uint StatusBadInternalError = 0x80020000u;

    private readonly string _driverInstanceId;
    private readonly CalculationDriverOptions _options;
    private readonly ILogger<CalculationDriver> _logger;
    private readonly ScriptRootLogger _scriptRoot;
    private readonly CalculationEvaluator _evaluator;

    // Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
    // DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
    // and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
    private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);

    // rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
    private readonly Dictionary<string, List<string>> _changeDependents = new(StringComparer.Ordinal);

    // Union of every tag's declared dependency refs — the interest set the host registers on the mux.
    // Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
    private IReadOnlyCollection<string> _dependencyRefs = Array.Empty<string>();

    // Live evaluation state. Guarded by _lock: OnDependencyValue (actor thread) and timer ticks
    // (pool threads) can both drive an evaluation, so all state mutation is serialized.
    private readonly object _lock = new();
    private readonly Dictionary<string, object?> _depValues = new(StringComparer.Ordinal);
    private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
    private readonly Dictionary<string, TagState> _stateByRawPath = new(StringComparer.Ordinal);

    private CalculationTimerScheduler? _timers;
    private volatile bool _running;
    private DateTime? _lastComputeUtc;
    private bool _disposed;

    /// <summary>Occurs when a calc tag's computed value changes (or transitions Good↔Bad).</summary>
    public event EventHandler<DataChangeEventArgs>? OnDataChange;

    /// <summary>Initializes a new <see cref="CalculationDriver"/>.</summary>
    /// <param name="options">Bound options carrying the authored raw calc tags.</param>
    /// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
    /// <param name="scriptRoot">Root script logger — user <c>ctx.Logger</c> output + failure entries fan out
    /// to the <c>script-logs</c> topic through its <c>ScriptLogTopicSink</c>. When null a no-op logger is used.</param>
    /// <param name="logger">Host-side diagnostics logger; defaults to the null logger.</param>
    public CalculationDriver(
        CalculationDriverOptions options,
        string driverInstanceId,
        ScriptRootLogger? scriptRoot = null,
        ILogger<CalculationDriver>? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _logger = logger ?? NullLogger<CalculationDriver>.Instance;
        _scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
        _evaluator = new CalculationEvaluator(
            _logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.Instance,
            _scriptRoot,
            _options.RunTimeout);

        RebuildTagTable(_options);
    }

    /// <summary>
    ///     (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
    ///     (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
    ///     and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
    ///     once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a
    ///     calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
    ///     and reinit paths share ONE construction routine and cannot drift.
    ///     <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known
    ///     value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
    ///     under <see cref="_lock"/> when invoked post-construction (the ctor runs single-threaded).</para>
    /// </summary>
    /// <param name="config">The (new) bound options carrying the authored raw calc tags.</param>
    private void RebuildTagTable(CalculationDriverOptions config)
    {
        _tagsByRawPath.Clear();
        _changeDependents.Clear();
        // Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
        var retainedState = new Dictionary<string, TagState>(StringComparer.Ordinal);

        foreach (var entry in config.RawTags)
        {
            if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
            {
                _logger.LogWarning(
                    "Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
                    _driverInstanceId, entry.RawPath);
                continue;
            }
            if (string.IsNullOrWhiteSpace(def.ScriptSource))
            {
                _logger.LogWarning(
                    "Calculation {Driver}: calc tag {RawPath} (script {Script}) has no resolved scriptSource; " +
                    "it will not compute until the deploy artifact injects the script source",
                    _driverInstanceId, entry.RawPath, def.ScriptId);
            }
            _tagsByRawPath[entry.RawPath] = def;
            // Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
            retainedState[entry.RawPath] =
                _stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
            if (def.ChangeTriggered)
            {
                foreach (var dep in def.DependencyRefs)
                {
                    if (!_changeDependents.TryGetValue(dep, out var list))
                        _changeDependents[dep] = list = new List<string>();
                    if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
                }
            }
        }

        // Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
        _stateByRawPath.Clear();
        foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;

        _dependencyRefs = _tagsByRawPath.Values
            .SelectMany(t => t.DependencyRefs)
            .Distinct(StringComparer.Ordinal)
            .ToArray();
    }

    // Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
    // tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
    private static readonly JsonSerializerOptions ReinitJsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        ReadCommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true,
        Converters = { new JsonStringEnumConverter() },
    };

    /// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
    /// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
    /// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
    /// current tags so a malformed delta can never blank the driver.</summary>
    private CalculationDriverOptions ParseOptions(string driverConfigJson)
    {
        if (!string.IsNullOrWhiteSpace(driverConfigJson))
        {
            try
            {
                var dto = JsonSerializer.Deserialize<ReinitConfigDto>(driverConfigJson, ReinitJsonOptions);
                if (dto is not null)
                {
                    return new CalculationDriverOptions
                    {
                        RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
                        RunTimeout = _options.RunTimeout,
                    };
                }
            }
            catch (JsonException ex)
            {
                _logger.LogWarning(ex,
                    "Calculation {Driver}: reinit config parse failed; keeping the current tag table",
                    _driverInstanceId);
            }
        }
        return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
    }

    /// <summary>Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
    /// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
    /// <c>CalculationDriverConfigDto</c>.</summary>
    private sealed class ReinitConfigDto
    {
        public List<RawTagEntry>? RawTags { get; init; }
        public int? RunTimeoutMs { get; init; }
    }

    // ---- IDriver ----

    /// <inheritdoc />
    public string DriverInstanceId => _driverInstanceId;

    /// <inheritdoc />
    public string DriverType => DriverTypeNames.Calculation;

    /// <inheritdoc />
    public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _running = true;
        StartTimers();
        _logger.LogInformation(
            "Calculation {Driver}: initialized with {TagCount} calc tag(s), {DepCount} distinct dependency ref(s)",
            _driverInstanceId, _tagsByRawPath.Count, _dependencyRefs.Count);
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        // A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
        // merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
        // respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
        // fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
        // here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
        // serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
        // reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
        // DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
        StopTimers();
        var newOptions = ParseOptions(driverConfigJson);
        lock (_lock)
        {
            RebuildTagTable(newOptions);
        }
        // Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
        // recompile from each tag's NEW ScriptSource) + re-arm timers off the rebuilt tag set.
        _evaluator.ClearCompiledScripts();
        _running = true;
        StartTimers();
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task ShutdownAsync(CancellationToken cancellationToken)
    {
        _running = false;
        StopTimers();
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);

    /// <inheritdoc />
    public long GetMemoryFootprint() => 0;

    /// <inheritdoc />
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
    {
        // The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
        _evaluator.ClearCompiledScripts();
        return Task.CompletedTask;
    }

    // ---- IDependencyConsumer ----

    /// <inheritdoc />
    public IReadOnlyCollection<string> DependencyRefs => _dependencyRefs;

    /// <inheritdoc />
    public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
    {
        if (!_running) return;
        List<string>? dependents;
        lock (_lock)
        {
            _depValues[rawPath] = value;
            _arrivedDeps.Add(rawPath);
            if (!_changeDependents.TryGetValue(rawPath, out dependents) || dependents.Count == 0) return;
            // Evaluate each change-triggered dependent inline under the lock (the compile cache makes this
            // a bounded method invocation — the same "fast enough to run inline" contract as the VT evaluator).
            foreach (var calcRawPath in dependents)
                EvaluateTag(calcRawPath, timestampUtc);
        }
    }

    // ---- ISubscribable ----

    /// <inheritdoc />
    public Task<ISubscriptionHandle> SubscribeAsync(
        IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(fullReferences);
        var handle = new CalculationSubscriptionHandle($"calc-{Guid.NewGuid():N}");
        // Initial-data callback (OPC UA convention): surface the current snapshot for each subscribed calc
        // tag so a freshly-materialised node shows BadWaitingForInitialData until its first evaluation lands.
        foreach (var reference in fullReferences)
        {
            var snapshot = ReadOne(reference);
            OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
        }
        return Task.FromResult<ISubscriptionHandle>(handle);
    }

    /// <inheritdoc />
    public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
        => Task.CompletedTask;

    // ---- IReadable ----

    /// <inheritdoc />
    public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(fullReferences);
        var results = new DataValueSnapshot[fullReferences.Count];
        for (var i = 0; i < fullReferences.Count; i++)
            results[i] = ReadOne(fullReferences[i]);
        return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
    }

    private DataValueSnapshot ReadOne(string reference)
    {
        var now = DateTime.UtcNow;
        lock (_lock)
        {
            if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue)
                return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
            var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood;
            return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
        }
    }

    // ---- evaluation ----

    /// <summary>Timer-trigger entry point — evaluates a single calc tag. Guards the lock itself (called
    /// from a pool thread, unlike the change path which is already under the lock).</summary>
    private void EvaluateTagLocked(string rawPath)
    {
        if (!_running) return;
        lock (_lock) EvaluateTag(rawPath, DateTime.UtcNow);
    }

    /// <summary>Evaluate one calc tag and publish the outcome. MUST be called under <see cref="_lock"/>.</summary>
    private void EvaluateTag(string rawPath, DateTime timestampUtc)
    {
        if (!_tagsByRawPath.TryGetValue(rawPath, out var def)) return;
        if (string.IsNullOrWhiteSpace(def.ScriptSource)) return; // unresolved script — never computes (logged at init)

        var st = _stateByRawPath[rawPath];

        // Change-gate (mini-design §4): publish nothing until every declared dep has arrived at least once.
        var allArrived = def.DependencyRefs.All(_arrivedDeps.Contains);

        // Build the per-tag dependency snapshot (only its declared reads, current values).
        var deps = new Dictionary<string, object?>(StringComparer.Ordinal);
        foreach (var dep in def.DependencyRefs)
            if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;

        var result = _evaluator.Evaluate(rawPath, def.ScriptSource, deps);

        if (!result.Success)
        {
            OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", allArrived, timestampUtc);
            return;
        }

        if (!allArrived) return; // change-trigger warm-up: hold until inputs are ready

        // Value dedup — but bypass it while recovering from Bad so a recovery whose value equals the
        // pre-failure value still republishes Good (otherwise the node would stay Bad forever).
        if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, result.Value))
            return;

        st.HasValue = true;
        st.LastValue = result.Value;
        st.LastPublishedBad = false;
        st.LastTimestampUtc = timestampUtc;
        _lastComputeUtc = DateTime.UtcNow;
        Publish(rawPath, result.Value, StatusGood, timestampUtc);
    }

    /// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): always emits the per-failure
    /// script-log; additionally publishes a Bad snapshot — carrying the last-known value — once per
    /// Good→Bad transition and only after every declared dependency has arrived.</summary>
    private void OnEvaluationFailed(
        CalculationTagDefinition def, TagState st, string reason, bool allArrived, DateTime timestampUtc)
    {
        PublishScriptLog(def, reason);
        if (st.LastPublishedBad || !allArrived) return;

        st.LastPublishedBad = true;
        st.LastTimestampUtc = timestampUtc;
        // Carry the last-known value (null if none) with Bad quality.
        Publish(def.RawPath, st.HasValue ? st.LastValue : null, StatusBadInternalError, timestampUtc);
    }

    private void Publish(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
    {
        var snapshot = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
        // Fired under _lock — the subscriber (DriverInstanceActor) marshals the event to its own thread,
        // so there is no re-entrant call back into this driver.
        OnDataChange?.Invoke(this, new DataChangeEventArgs(SharedHandle, rawPath, snapshot));
    }

    private void PublishScriptLog(CalculationTagDefinition def, string reason)
    {
        // Route through the script logger bound with ScriptId + the calc tag's RawPath (in the VirtualTagId
        // slot the Script-log page attributes on). The ScriptRootLogger's ScriptLogTopicSink converts this
        // Warning event into a ScriptLogEntry on the script-logs DPS topic — the driver needs no Akka access.
        _scriptRoot.Logger
            .ForContext(ScriptLoggerFactory.ScriptIdProperty, def.ScriptId)
            .ForContext(ScriptLoggerFactory.VirtualTagIdProperty, def.RawPath)
            .Warning("{Reason}", reason);
    }

    private void StartTimers()
    {
        var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
        if (timed.Length == 0) return;
        _timers = new CalculationTimerScheduler(EvaluateTagLocked, _logger);
        _timers.Start(timed);
    }

    private void StopTimers()
    {
        _timers?.Dispose();
        _timers = null;
    }

    /// <summary>Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.</summary>
    internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;

    // A single shared handle for host-pushed publishes (change + timer). SubscribeAsync mints its own
    // per-call handle for the initial-data callback; steady-state publishes reuse this so OnDataChange
    // always carries a non-null handle.
    private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");

    /// <inheritdoc />
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        _running = false;
        StopTimers();
        _evaluator.Dispose();
    }

    /// <summary>Per-tag published state (guarded by <see cref="_lock"/>).</summary>
    private sealed class TagState
    {
        public bool HasValue;
        public object? LastValue;
        public bool LastPublishedBad;
        public DateTime LastTimestampUtc;
    }

    private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
}
