using System.Text;
using Npgsql;
using NpgsqlTypes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
///
/// QuestDB-specific read behaviour (the shared Raw/AtTime/Processed literals run through the conformance suite):
/// tombstones, the pre-1970 / whole-range bucket math, reverse paging at one value per page, QuestDB's LONG NULL
/// sentinel, the kind-decides-the-value rule, AtTime chunking, Events and failure accounting.
///
[Collection(QuestDbCollection.Name)]
public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
{
private static readonly CancellationToken Ct = CancellationToken.None;
private const uint Good = ConformanceData.Good;
private const uint Bad = ConformanceData.Bad;
private const uint GoodCalc = 0x00000401u;
private const uint UncertainCalc = 0x40A40401u;
private const uint UncertainInterpolated = 0x40A40402u;
private const uint GoodInterpolated = 0x00000402u;
private const uint NoDataCalc = 0x809B0401u;
private QuestDbConformanceBackend _b = null!;
private static DateTime At(double s) => ConformanceData.At(s);
public async ValueTask InitializeAsync() => _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
public async ValueTask DisposeAsync()
{
if (_b is not null) await _b.DisposeAsync();
}
private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray();
/// What T6's DeleteAtTime writes: the same (ts, tag), deleted = true, value columns NULL, status BadNoData.
private Task TombstoneAsync(string tag, DateTime t) =>
_b.InsertRowsAsync(tag, [(t, HistorianValue.Empty, HistorianStatusCodes.BadNoData, true)]);
[Fact]
public async Task Tombstoned_rows_are_invisible_to_raw_attime_and_processed()
{
await ConformanceData.SeedAAsync(_b); // +0=10, +10=20, +20=Bad 30, +30=40, +40=50
await TombstoneAsync("A", At(10)); // DEDUP replaces the +10 row with a tombstone
(await _b.ReadBackAsync("A")).Count.ShouldBe(4);
var src = _b.CreateDataSource();
var raw = await src.ReadRawAsync("A", At(0), At(100), 0, Ct);
Values(raw).ShouldBe([10d, 30d, 40d, 50d]);
raw.Samples.Select(s => s.StatusCode).ShouldBe([Good, Bad, Good, Good]);
// +10 is no longer an exact hit: previous is +0 = 10 (Good), next is the Bad +20 ⇒ held, Uncertain.
var at = (await src.ReadAtTimeAsync("A", [At(10), At(5)], Ct)).Samples;
at.Select(s => s.Value).ShouldBe([(object?)10d, 10d]);
at.ShouldAllBe(s => s.StatusCode == UncertainInterpolated);
// B0 [0,20) holds only {10}: Good, count 1. A visible tombstone would be a Bad sample ⇒ Uncertain, PercentBad 50.
async Task B0(HistoryAggregateType a) =>
(await src.ReadProcessedAsync("A", At(0), At(80), TimeSpan.FromSeconds(20), a, Ct)).Samples[0];
var avg = await B0(HistoryAggregateType.Average);
avg.Value.ShouldBe(10d);
avg.StatusCode.ShouldBe(GoodCalc);
(await B0(HistoryAggregateType.Count)).Value.ShouldBe(1);
(await B0(HistoryAggregateType.PercentBad)).Value.ShouldBe(0d);
(await B0(HistoryAggregateType.End)).Value.ShouldBe(10d);
}
[Fact]
public async Task A_whole_range_processed_read_from_1601_to_9999_with_interval_zero_does_not_overflow()
{
await ConformanceData.SeedAAsync(_b);
var src = _b.CreateDataSource();
var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var end = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc);
foreach (var agg in new[] { HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.MaximumActualTime })
{
var s = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, agg, Ct)).Samples.Single();
s.StatusCode.ShouldBe(UncertainCalc, agg.ToString());
}
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single().Value.ShouldBe(30d);
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Start, Ct)).Samples.Single().Value.ShouldBe(10d);
var max = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single();
max.Value.ShouldBe(50d);
max.SourceTimestampUtc.ShouldBe(_b.Round(At(40)));
// The extreme DateTime range: MinValue/MaxValue are nudged (never bound as ±infinity, which would match nothing).
(await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Total, Ct))
.Samples.Single().Value.ShouldBe(120d);
// A pre-1970 window holds no data and is not an error.
(await src.ReadProcessedAsync("A", start, new DateTime(1969, 1, 1, 0, 0, 0, DateTimeKind.Utc), TimeSpan.FromDays(3650),
HistoryAggregateType.Count, Ct)).Samples.ShouldAllBe(x => x.StatusCode == NoDataCalc);
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
///
/// The node manager's reverse paging at NumValuesPerNode = 1 (OtOpcUaNodeManager + HistoryPaging): resume at
/// the last timestamp inclusive and trim it; when the trimmed page stalls on that timestamp, point-read it and step
/// one tick down. One tick below a µs-aligned time rounds to the previous µs, so the walk advances.
///
[Fact]
public async Task A_reverse_paging_walk_with_one_value_per_page_returns_every_row()
{
await ConformanceData.SeedAAsync(_b);
var src = _b.CreateDataSource();
var end = At(-1);
DateTime start = At(100);
var resumed = false;
var emitted = new List