using Akka.Actor;
using Akka.TestKit.Xunit2;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;

/// <summary>
///     Verifies the <see cref="ContinuousHistorizationRecorder"/>: it registers historized-ref
///     interest with the dependency mux on start, appends mux-fanned
///     <see cref="VirtualTagActor.DependencyValueChanged"/> values to the durable outbox then drains
///     them to the live-value writer, retains entries when the writer fails, and drops non-numeric
///     values (the SQL analog write path is numeric-only).
/// </summary>
/// <remarks>
///     Adapted from the plan's Task 17: the recorder handles the REAL fan-out message the mux emits —
///     <see cref="VirtualTagActor.DependencyValueChanged"/> (TagId/Value/TimestampUtc, no quality) —
///     not <c>DriverInstanceActor.AttributeValuePublished</c>. The mux drops quality, so the recorder
///     records Good-quality (the same convention the scripted-alarm host uses for mux values).
/// </remarks>
public sealed class ContinuousHistorizationRecorderTests : TestKit
{
    [Fact]
    public void Registers_interest_for_historized_refs_on_start()
    {
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter();
        var outbox = new InMemoryOutbox();

        Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, historizedRefs: new[] { "Pump1.Temp" }));

        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Contains("Pump1.Temp", reg.TagRefs);
    }

    [Fact]
    public void UpdateHistorizedRefs_from_empty_registers_the_added_refs()
    {
        var mux = CreateTestProbe();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));

        // PreStart registers the (empty) initial set first.
        var initial = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Empty(initial.TagRefs);

        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { HistorizedTagRef.ForSelf("Pump1.Temp"), HistorizedTagRef.ForSelf("Pump2.Flow") },
            Array.Empty<HistorizedTagRef>()));

        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Equal(new[] { "Pump1.Temp", "Pump2.Flow" }, reg.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
    }

    [Fact]
    public void UpdateHistorizedRefs_converges_adding_and_removing()
    {
        var mux = CreateTestProbe();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)

        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { HistorizedTagRef.ForSelf("A"), HistorizedTagRef.ForSelf("B") }, Array.Empty<HistorizedTagRef>()));
        var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Equal(new[] { "A", "B" }, first.TagRefs.OrderBy(x => x, StringComparer.Ordinal));

        // Add "C", remove "A" → converge to {B, C}. The mux's RegisterInterest is a full-REPLACE, so the
        // recorder sends ONE RegisterInterest carrying exactly the converged set (C registered, A gone).
        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { HistorizedTagRef.ForSelf("C") }, new[] { HistorizedTagRef.ForSelf("A") }));
        var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Equal(new[] { "B", "C" }, second.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
        Assert.DoesNotContain("A", second.TagRefs);
    }

    [Fact]
    public void UpdateHistorizedRefs_is_idempotent_when_the_set_is_unchanged()
    {
        var mux = CreateTestProbe();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: new[] { "A", "B" }));
        var initial = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart {A, B}
        Assert.Equal(new[] { "A", "B" }, initial.TagRefs.OrderBy(x => x, StringComparer.Ordinal));

        // A delta with no net effect (add an already-present ref, remove an absent one) → no mux churn.
        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { HistorizedTagRef.ForSelf("A") }, new[] { HistorizedTagRef.ForSelf("Z") }));
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    [Fact]
    public void UpdateHistorizedRefs_draining_to_empty_unregisters_all_interest()
    {
        var mux = CreateTestProbe();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: new[] { "A" }));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart {A}

        // The mux has no per-ref unregister; an empty converged set drops ALL interest in one message.
        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            Array.Empty<HistorizedTagRef>(), new[] { HistorizedTagRef.ForSelf("A") }));
        mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>();
    }

    [Fact]
    public async Task HistorianTagname_override_registers_by_mux_ref_but_writes_under_historian_name()
    {
        // FU-3: a HistorianTagname override makes the mux ref (driver FullName the mux fans by) DIVERGE
        // from the historian name (the value is stored under). Interest MUST be registered under the mux
        // ref (else the mux never fans this tag), and the value MUST be written under the override name.
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter { Succeed = true };
        var outbox = new InMemoryOutbox();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, historizedRefs: Array.Empty<string>()));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)

        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { new HistorizedTagRef("Area/Line/Pump1", "HIST.Pump1.Temp") },
            Array.Empty<HistorizedTagRef>()));

        // Interest is registered under the MUX REF (the driver ref the mux keys fan-out by), NOT the name.
        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Equal(new[] { "Area/Line/Pump1" }, reg.TagRefs);

        // The mux fans the value keyed by the driver ref; the recorder must historize it under the OVERRIDE.
        rec.Tell(new VirtualTagActor.DependencyValueChanged("Area/Line/Pump1", 55.0, DateTime.UtcNow));

        await AwaitAssertAsync(() =>
            Assert.Contains(writer.Snapshot(), w => w.Tag == "HIST.Pump1.Temp" && w.Value == 55.0));
        // ...and NEVER under the mux ref (the pre-fix behaviour wrote under msg.TagId == the mux ref).
        Assert.DoesNotContain(writer.Snapshot(), w => w.Tag == "Area/Line/Pump1");
    }

    [Fact]
    public async Task Override_rename_with_same_mux_ref_updates_target_without_mux_churn()
    {
        // An override changing while the driver FullName (mux ref) stays the same: removed+added carry the
        // SAME mux ref with different historian names. The recorder must update the write target but NOT
        // re-register the mux (the fanned key-set is unchanged).
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter { Succeed = true };

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)

        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { new HistorizedTagRef("Area/Pump1", "HIST.Old") }, Array.Empty<HistorizedTagRef>()));
        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Equal(new[] { "Area/Pump1" }, reg.TagRefs);

        // Override renamed: same mux ref, new historian name. Key-set unchanged → no further mux message.
        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[] { new HistorizedTagRef("Area/Pump1", "HIST.New") },
            new[] { new HistorizedTagRef("Area/Pump1", "HIST.Old") }));
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        // ...and the WRITE TARGET actually updated: a value now historizes under HIST.New, never HIST.Old.
        rec.Tell(new VirtualTagActor.DependencyValueChanged("Area/Pump1", 9.0, DateTime.UtcNow));
        await AwaitAssertAsync(() =>
            Assert.Contains(writer.Snapshot(), w => w.Tag == "HIST.New" && w.Value == 9.0));
        Assert.DoesNotContain(writer.Snapshot(), w => w.Tag == "HIST.Old");
    }

    [Fact]
    public async Task Aliased_refs_with_distinct_overrides_each_get_the_value()
    {
        // Two historized tags share ONE driver ref ("Area/Pump1" — identical machines sharing a register),
        // each with its OWN HistorianTagname. The mux fans a SINGLE value for the shared ref; BOTH historian
        // tags must be written. A muxRef→single-name map would silently drop one (the FU-3 review's Critical).
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter { Succeed = true };
        var outbox = new InMemoryOutbox();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, historizedRefs: Array.Empty<string>()));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)

        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[]
            {
                new HistorizedTagRef("Area/Pump1", "HIST.MachineA.Temp"),
                new HistorizedTagRef("Area/Pump1", "HIST.MachineB.Temp"),
            },
            Array.Empty<HistorizedTagRef>()));

        // ONE mux ref is registered (both aliases share it).
        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        Assert.Equal(new[] { "Area/Pump1" }, reg.TagRefs);

        rec.Tell(new VirtualTagActor.DependencyValueChanged("Area/Pump1", 71.0, DateTime.UtcNow));

        // BOTH historian names receive the single fanned value.
        await AwaitAssertAsync(() =>
        {
            var snap = writer.Snapshot();
            Assert.Contains(snap, w => w.Tag == "HIST.MachineA.Temp" && w.Value == 71.0);
            Assert.Contains(snap, w => w.Tag == "HIST.MachineB.Temp" && w.Value == 71.0);
        });
    }

    [Fact]
    public void Removing_one_alias_keeps_the_shared_mux_ref_registered_for_the_other()
    {
        // Removing ONE alias's historian name from a shared mux ref must NOT drop the mux registration —
        // the ref is still fanned for the surviving alias (key-set unchanged → no mux churn).
        var mux = CreateTestProbe();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)

        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            new[]
            {
                new HistorizedTagRef("Area/Pump1", "HIST.A"),
                new HistorizedTagRef("Area/Pump1", "HIST.B"),
            },
            Array.Empty<HistorizedTagRef>()));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // {Area/Pump1}

        // Drop only HIST.A — Area/Pump1 still fans for HIST.B, so the key-set is unchanged: no mux churn.
        rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
            Array.Empty<HistorizedTagRef>(), new[] { new HistorizedTagRef("Area/Pump1", "HIST.A") }));
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    [Fact]
    public async Task DependencyValueChanged_appends_to_outbox_then_drains_to_writer()
    {
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter { Succeed = true };
        var outbox = new InMemoryOutbox();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, new[] { "Pump1.Temp" }));

        rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Temp", 42.0, DateTime.UtcNow));

        await AwaitAssertAsync(() =>
            Assert.Contains(writer.Snapshot(), w => w.Tag == "Pump1.Temp" && w.Value == 42.0));
        await AwaitAssertAsync(async () =>
            Assert.Equal(0, await outbox.CountAsync(default)));   // acked -> truncated
    }

    [Fact]
    public async Task Writer_failure_keeps_entry_for_retry()
    {
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter { Succeed = false };
        var outbox = new InMemoryOutbox();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, new[] { "Pump1.Temp" }));

        rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Temp", 7.0, DateTime.UtcNow));

        // Prove the drain actually RAN, not just that the append happened: assert the writer was
        // invoked (the fake records every value, even on Succeed=false) AND that the outbox stayed at 1
        // (RemoveAsync was NOT called, so the un-acked entry is retained for retry). Count==1 alone is
        // true the instant the append lands and would not catch a silently-broken drain.
        await AwaitAssertAsync(async () =>
        {
            Assert.Contains(writer.Snapshot(), w => w.Tag == "Pump1.Temp" && w.Value == 7.0);
            Assert.Equal(1, await outbox.CountAsync(default));
        });
    }

    [Fact]
    public async Task Retry_after_writer_failure_eventually_acks()
    {
        var mux = CreateTestProbe();
        // First drain fails, the next succeeds — exercises the retry → success → ack path.
        var writer = new FakeValueWriter { Succeed = true, FailFirstN = 1 };
        var outbox = new InMemoryOutbox();

        // Short backoff so the retry fires promptly; the assert below is still time-bounded and
        // deterministic (AwaitAssert polls — no Thread.Sleep).
        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, new[] { "Pump1.Temp" },
            minBackoff: TimeSpan.FromMilliseconds(50),
            maxBackoff: TimeSpan.FromMilliseconds(200)));

        rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Temp", 13.0, DateTime.UtcNow));

        // The first drain returns false (entry retained); after the backoff the retry drain succeeds
        // and acks, truncating the outbox to 0.
        //
        // BOTH conditions are polled together, and the retry count is the load-bearing one. An empty
        // outbox is NOT a distinguishing observation: it is equally true BEFORE the append lands, so
        // waiting on it alone can be satisfied by the initial state and return before the recorder has
        // done anything at all. That is exactly what happened intermittently under a fully parallel
        // assembly run — the poll won the race against the first append, the wait returned immediately,
        // and the CallCount check that used to sit outside the block then failed against a recorder that
        // had not yet run. (The same trap is called out on the preceding test, which guards against it by
        // pairing its count with a writer observation.)
        await AwaitAssertAsync(
            async () =>
            {
                Assert.True(
                    writer.CallCount >= 2,
                    "the writer must have been called at least twice (a retry happened)");
                Assert.Equal(0, await outbox.CountAsync(default));
            },
            TimeSpan.FromSeconds(5));
    }

    [Fact]
    public async Task Non_numeric_value_is_dropped_with_metric()
    {
        var mux = CreateTestProbe();
        var writer = new FakeValueWriter();
        var outbox = new InMemoryOutbox();

        var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
            mux.Ref, writer, outbox, new[] { "Pump1.Name" }));

        rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Name", "text", DateTime.UtcNow));

        // A string value can't ride the SQL analog write path -> dropped (metered), never appended.
        await AwaitAssertAsync(async () =>
        {
            var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
                ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
            Assert.Equal(1, status.DroppedNonNumeric);
            Assert.Equal(0, status.QueuedDepth);
        });
        Assert.Empty(writer.Snapshot());
    }

    /// <summary>In-memory <see cref="IHistorianValueWriter"/> double: records every value written and
    /// returns <see cref="Succeed"/> as the ack. Thread-safe — the recorder drains off the actor thread.</summary>
    private sealed class FakeValueWriter : IHistorianValueWriter
    {
        private readonly Lock _gate = new();
        private readonly List<WrittenValue> _written = new();
        private int _calls;

        public bool Succeed { get; init; } = true;

        /// <summary>When &gt; 0, the first N calls fail (return false) regardless of <see cref="Succeed"/>;
        /// later calls use <see cref="Succeed"/>. Lets a test prove the retry → success → ack path while
        /// leaving the plain <see cref="Succeed"/> behaviour (FailFirstN==0) untouched.</summary>
        public int FailFirstN { get; init; }

        /// <summary>Lifetime count of <see cref="WriteLiveValuesAsync"/> invocations (proves a retry ran).</summary>
        public int CallCount
        {
            get
            {
                lock (_gate)
                {
                    return _calls;
                }
            }
        }

        public IReadOnlyList<WrittenValue> Snapshot()
        {
            lock (_gate)
            {
                return _written.ToArray();
            }
        }

        public Task<bool> WriteLiveValuesAsync(
            string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
        {
            lock (_gate)
            {
                _calls++;
                foreach (HistorizationValue v in values)
                {
                    _written.Add(new WrittenValue(tag, v.Value, v.Quality, v.TimestampUtc));
                }

                // Fail the first FailFirstN calls; thereafter honour Succeed.
                bool result = _calls > FailFirstN && Succeed;
                return Task.FromResult(result);
            }
        }
    }

    private sealed record WrittenValue(string Tag, double Value, ushort Quality, DateTime? TimestampUtc);

    /// <summary>In-memory <see cref="IHistorizationOutbox"/> double honouring the FIFO-truncate
    /// <see cref="IHistorizationOutbox.RemoveAsync"/> contract (remove the id plus any older entries
    /// ahead of it) and the optional drop-oldest capacity. Thread-safe.</summary>
    private sealed class InMemoryOutbox : IHistorizationOutbox
    {
        private readonly Lock _gate = new();
        private readonly List<HistorizationOutboxEntry> _entries = new();
        private readonly int _capacity;
        private long _dropped;

        public InMemoryOutbox(int capacity = 0) => _capacity = capacity;

        public long DroppedCount
        {
            get
            {
                lock (_gate)
                {
                    return _dropped;
                }
            }
        }

        public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct)
        {
            lock (_gate)
            {
                _entries.Add(entry);
                while (_capacity > 0 && _entries.Count > _capacity)
                {
                    _entries.RemoveAt(0);   // drop oldest on overflow
                    _dropped++;
                }
            }

            return ValueTask.CompletedTask;
        }

        public ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct)
        {
            lock (_gate)
            {
                IReadOnlyList<HistorizationOutboxEntry> batch = _entries.Take(max).ToArray();
                return ValueTask.FromResult(batch);
            }
        }

        public ValueTask RemoveAsync(Guid id, CancellationToken ct)
        {
            lock (_gate)
            {
                int idx = _entries.FindIndex(e => e.Id == id);
                if (idx >= 0)
                {
                    // FIFO ack: remove the target plus everything ahead of it in the buffer.
                    _entries.RemoveRange(0, idx + 1);
                }
            }

            return ValueTask.CompletedTask;
        }

        public ValueTask<int> CountAsync(CancellationToken ct)
        {
            lock (_gate)
            {
                return ValueTask.FromResult(_entries.Count);
            }
        }

        public void Dispose()
        {
        }
    }
}
