using System.Collections.Concurrent;
using Serilog;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

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

/// <summary>
///     WP7 unit tests for <see cref="CalculationDriver"/>: dependency-ref extraction from the authored
///     tags, the change-gate (no publish until every declared dep has arrived), value dedup, the
///     Good→Bad transition + <c>ScriptLogEntry</c> emission + Good recovery, and timer-trigger recompute.
/// </summary>
public sealed class CalculationDriverTests
{
    private const uint StatusGood = 0u;
    private const uint StatusBadInternalError = 0x80020000u;
    private const uint StatusBadWaitingForInitialData = 0x80320000u;

    private static RawTagEntry CalcTag(string rawPath, string source, bool changeTriggered = true, int? timerMs = null)
    {
        var timer = timerMs is { } ms ? $",\"timerIntervalMs\":{ms}" : "";
        var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":{(changeTriggered ? "true" : "false")}{timer}," +
                   $"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
        return new RawTagEntry(rawPath, json, WriteIdempotent: false);
    }

    private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
        Build(params RawTagEntry[] tags)
    {
        var logs = new List<ScriptLogEntry>();
        var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
        var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
        var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
        var published = new ConcurrentQueue<DataChangeEventArgs>();
        driver.OnDataChange += (_, e) => published.Enqueue(e);
        return (driver, published, logs);
    }

    // ── dependency-ref extraction ─────────────────────────────────────────────────────────────

    [Fact]
    public void DependencyRefs_are_the_union_of_authored_tag_scripts()
    {
        var (driver, _, _) = Build(
            CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"),
            CalcTag("calc/e/y", "return (double)ctx.GetTag(\"src/b\").Value + (double)ctx.GetTag(\"src/c\").Value;"));

        driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b", "src/c" });
    }

    // ── change-gate ───────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task No_publish_until_every_declared_dep_has_arrived()
    {
        var (driver, published, _) = Build(
            CalcTag("calc/e/sum", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"));
        await driver.InitializeAsync("{}", default);

        // Only one of two deps arrived → the change-gate holds.
        driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
        published.ShouldBeEmpty();

        // Second dep arrives → now it computes + publishes 30.
        driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
        published.Count.ShouldBe(1);
        published.TryDequeue(out var e).ShouldBeTrue();
        e!.FullReference.ShouldBe("calc/e/sum");
        e.Snapshot.Value.ShouldBe(30.0);
        e.Snapshot.StatusCode.ShouldBe(StatusGood);
    }

    // ── dedup + recompute ─────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Equal_result_is_deduped_and_a_changed_result_republishes()
    {
        var (driver, published, _) = Build(
            CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
        await driver.InitializeAsync("{}", default);

        driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow);   // → 10 (publish)
        driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow);   // → 10 (dedup, no publish)
        driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow);   // → 14 (publish)

        published.Select(p => p.Snapshot.Value).ShouldBe(new object?[] { 10.0, 14.0 });
    }

    // ── Bad transition + script-log + recovery ────────────────────────────────────────────────

    [Fact]
    public async Task Failure_publishes_Bad_once_per_transition_emits_script_log_and_recovers()
    {
        var (driver, published, logs) = Build(
            CalcTag("calc/e/div", "return 100 / (int)ctx.GetTag(\"src/a\").Value;"));
        await driver.InitializeAsync("{}", default);

        // a = 0 → DivideByZero → Failure → Bad (carrying last-known = null) + one script-log.
        driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
        published.Count.ShouldBe(1);
        published.TryDequeue(out var bad).ShouldBeTrue();
        bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
        logs.Count.ShouldBe(1);
        logs[0].Level.ShouldBe("Warning");
        logs[0].ScriptId.ShouldBe("s-calc/e/div");
        logs[0].VirtualTagId.ShouldBe("calc/e/div");

        // a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
        driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
        published.ShouldBeEmpty();
        logs.Count.ShouldBe(2);

        // a = 5 → 20 → Good recovery is force-published.
        driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
        published.Count.ShouldBe(1);
        published.TryDequeue(out var good).ShouldBeTrue();
        good!.Snapshot.Value.ShouldBe(20);
        good.Snapshot.StatusCode.ShouldBe(StatusGood);
    }

    // ── timer trigger ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Timer_only_tag_computes_on_its_interval()
    {
        // No dependencies (constant script) so only the timer can drive it.
        var (driver, published, _) = Build(
            CalcTag("calc/e/const", "return 42.0;", changeTriggered: false, timerMs: 50));
        await driver.InitializeAsync("{}", default);

        var fired = await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(3));
        fired.ShouldBeTrue("the timer trigger should have computed the tag within 3s");
        published.TryDequeue(out var e).ShouldBeTrue();
        e!.FullReference.ShouldBe("calc/e/const");
        e.Snapshot.Value.ShouldBe(42.0);

        await driver.ShutdownAsync(default);
    }

    // ── IReadable ─────────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Read_returns_BadWaitingForInitialData_before_first_eval_then_the_computed_value()
    {
        var (driver, _, _) = Build(CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
        await driver.InitializeAsync("{}", default);

        var before = await driver.ReadAsync(new[] { "calc/e/x" }, default);
        before[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);

        driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
        var after = await driver.ReadAsync(new[] { "calc/e/x" }, default);
        after[0].StatusCode.ShouldBe(StatusGood);
        after[0].Value.ShouldBe(6.0);
    }

    [Fact]
    public void Health_is_always_Connected()
    {
        var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;"));
        driver.GetHealth().State.ShouldBe(DriverState.Healthy);
    }

    private static async Task<bool> WaitUntil(Func<bool> predicate, TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (DateTime.UtcNow < deadline)
        {
            if (predicate()) return true;
            await Task.Delay(20);
        }
        return predicate();
    }

    private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
    {
        private readonly object _lock = new();
        public void Publish(ScriptLogEntry entry)
        {
            lock (_lock) sink.Add(entry);
        }
    }
}
