using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;

/// <summary>
///     #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
///     alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
///     transition (which for a still-active alarm with static dependencies may never come).
/// </summary>
/// <remarks>
///     The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
///     state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
///     so the transition stream cannot carry its state. These tests pin both halves — the projection does
///     report the state, and reading it does not fire an emission.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmProjectionTests
{
    private static ScriptedAlarmDefinition Alarm(
        string id = "HighTemp",
        string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
        string msg = "Temp is {Temp}",
        AlarmSeverity sev = AlarmSeverity.High) =>
        new(AlarmId: id,
            EquipmentPath: "Plant/Line1/Reactor",
            AlarmName: id,
            Kind: AlarmKind.AlarmCondition,
            Severity: sev,
            MessageTemplate: msg,
            PredicateScriptSource: predicate);

    /// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
    private static AlarmConditionState PersistedActive(
        string alarmId = "HighTemp",
        AlarmAckedState acked = AlarmAckedState.Unacknowledged,
        DateTime? lastTransitionUtc = null) =>
        new(alarmId,
            AlarmEnabledState.Enabled,
            AlarmActiveState.Active,
            acked,
            AlarmConfirmedState.Confirmed,
            ShelvingState.Unshelved,
            lastTransitionUtc ?? DateTime.UtcNow,
            LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
            LastClearedUtc: null,
            LastAckUtc: null, LastAckUser: null, LastAckComment: null,
            LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
            Comments: []);

    private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
    {
        var logger = new LoggerConfiguration().CreateLogger();
        return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
    }

    /// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
    /// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
    [Fact]
    public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150); // predicate still true — no transition to report
        var store = new InMemoryAlarmStateStore();
        await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);

        using var eng = Build(up, store);
        var emissions = new List<ScriptedAlarmEvent>();
        eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load

        await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);

        // The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
        // in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
        lock (emissions)
        {
            emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
        }

        var projection = eng.GetProjections().ShouldHaveSingleItem();
        projection.AlarmId.ShouldBe("HighTemp");
        projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
        projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
    }

    /// <summary>The projection carries the definition's severity and the message template resolved against
    /// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
    [Fact]
    public async Task Projection_carries_definition_severity_and_a_resolved_message()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        var store = new InMemoryAlarmStateStore();
        await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);

        using var eng = Build(up, store);
        await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);

        var projection = eng.GetProjections().ShouldHaveSingleItem();
        projection.Severity.ShouldBe(AlarmSeverity.Critical);
        projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
    }

    /// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
    /// or every deploy would append a duplicate historian / alerts row.</summary>
    [Fact]
    public async Task Reading_projections_never_fires_an_emission()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        var store = new InMemoryAlarmStateStore();
        await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);

        using var eng = Build(up, store);
        await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);

        var emissions = 0;
        eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);

        for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();

        Volatile.Read(ref emissions).ShouldBe(0);
    }

    /// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
    /// "nothing to restore" from "restore this".</summary>
    [Fact]
    public async Task A_normal_alarm_projects_the_no_event_position()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 50); // predicate false
        using var eng = Build(up, new InMemoryAlarmStateStore());

        await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);

        var projection = eng.GetProjections().ShouldHaveSingleItem();
        projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
        projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
        projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
        projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
    }

    /// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
    /// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
    [Fact]
    public async Task Projection_carries_the_last_observed_worst_input_quality()
    {
        var up = new FakeUpstream();
        up.Set("Temp", 150);
        var store = new InMemoryAlarmStateStore();
        await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);

        using var eng = Build(up, store);
        await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
        eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");

        up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated

        // The push re-evaluates on a background worker; poll for the tracked bucket to move.
        var deadline = DateTime.UtcNow.AddSeconds(10);
        while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
        {
            await Task.Delay(20, TestContext.Current.CancellationToken);
        }

        (eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
    }

    /// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
    [Fact]
    public void Projections_are_empty_before_the_first_load()
    {
        using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
        eng.GetProjections().ShouldBeEmpty();
    }
}
