using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
/// The end bound still owed to a HistoryRead-Raw read with ReturnBounds = true (Gitea #691). It rides the
/// continuation point, because Part 11 returns the end bound with the LAST page only.
///
/// The request's end time (the later bound of a forward read, the earlier of a reverse one).
/// The read is newest-first (StartTime > EndTime, including an end-only read).
///
/// false only for StartTime == EndTime: the samples AT that instant are the data, so the end bound is the
/// first sample strictly after it (Part 11 Table 1 footnote c). Otherwise the end bound is the sample at or beyond the
/// end time, and the data window excludes the end time.
///
/// The end time was not given (a one-sided read's sentinel): there is no end bound to look up.
/// The data is drained and the end bound did not fit on the previous page.
/// The last value's time returned on an earlier page (stamps an open bound's placeholder).
///
/// Gitea #692: the end bound an earlier page already looked up (a stored sample — never a placeholder), so the page that
/// returns it does not read the backend again. null = not looked up yet.
///
internal sealed record HistoryEndBound(
DateTime AtUtc, bool Reverse, bool Inclusive, bool Open, bool DataExhausted = false, DateTime? LastReturnedUtc = null,
DataValueSnapshot? Resolved = null);
///
/// Gitea #691: OPC UA Part 11 bounding values for HistoryRead-Raw (ReadRawModifiedDetails.ReturnBounds), composed
/// in the server from every backend's existing raw read — the ONE authority, so SqlServer, QuestDB, Gateway and Null
/// answer identically.
///
///
///
/// What a bound is (Part 11 §4.6, Table 1). Forward: the start bound is the stored sample AT or immediately
/// BEFORE StartTime, the end bound the one AT or immediately AFTER EndTime. Reverse mirrors it: the start bound is at
/// or after StartTime, the end bound at or before EndTime. A sample exactly on a bound IS the bound: on the start it
/// is already the first sample of the data (the window includes StartTime), so nothing extra is added; on the end it is
/// added (the window excludes EndTime). StartTime == EndTime reads forward, and its end bound is the first
/// sample strictly after the instant. A bound is a stored sample of any quality — a Bad sample is returned as stored.
///
///
/// Missing bound. Bad_BoundNotFound, a null value, stamped with the bound's time. A one-sided read's
/// missing side (no EndTime / no StartTime) is never looked up; it is Bad_BoundNotFound stamped one second after
/// (forward) or before (reverse) the previous returned value (Table 1 footnotes a and b).
///
///
/// Lookups. "At or before T" is a newest-first read of (0, T] capped at one — native on a backend that
/// declares , otherwise 's walk-back
/// from bounded forward reads (the Gateway: NOT AVEVA's synthetic boundary row, which is stamped at the query start
/// rather than at the prior sample's own time). "At or after T" is a forward read of [T, MaxValue) capped at one (on a
/// forward-only backend ending at now + instead — #691 review);
/// "strictly after T" starts one storage resolution later (lossless: nothing is stored in between). Inside a tie
/// cluster the bound is the cluster's first sample in the read's direction — the forward read's first, the reverse
/// read's first (the forward order's last).
///
///
/// Resolution. "On the bound" compares at the backend's storage resolution, because a microsecond backend
/// (QuestDB) truncates the window bounds it is sent: a sample at ⌊T⌋ is inside a window starting at T
/// there, so it must not also be returned as the start bound.
///
///
internal static class HistoryBounds
{
private static readonly TimeSpan OpenBoundStep = TimeSpan.FromSeconds(1);
///
/// Gitea #692: the placeholders this server synthesised (), by reference. A placeholder is known by
/// this marker, never by its shape: a stored row can legitimately carry Bad_BoundNotFound with a null value (a
/// client may write one through HistoryUpdate), and that row is data.
///
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable Placeholders = new();
private static readonly object PlaceholderMark = new();
/// The end bound owed by a fresh read over the normalised window.
/// The (normalised) start: the later time of a reverse read.
/// The (normalised) end; a one-sided read's sentinel (tick 0 / MaxValue) is an open bound.
/// The pending end bound.
public static HistoryEndBound For(DateTime startUtc, DateTime endUtc) =>
new(endUtc, Reverse: startUtc > endUtc, Inclusive: startUtc != endUtc, Open: IsOpen(endUtc));
///
/// Whether a returned value is a placeholder this server synthesised rather than a stored sample
/// — by an explicit marker (#692), so a stored row whose status happens to be Bad_BoundNotFound is not taken for one.
///
/// The value.
/// true only for an instance produced.
public static bool IsPlaceholder(DataValueSnapshot value) => Placeholders.TryGetValue(value, out _);
/// The pending end bound to store behind a continuation point, remembering the page's last returned time.
/// The pending end bound (null = bounds not requested).
/// The page being returned.
/// The bound to carry, or null.
public static HistoryEndBound? Carry(HistoryEndBound? bound, IReadOnlyList page) =>
bound is null || page.Count == 0 || page[^1].SourceTimestampUtc is not { } last
? bound
: bound with { LastReturnedUtc = last };
/// The Bad_BoundNotFound placeholder (null value) stamped at .
/// The bound's time.
/// The placeholder.
public static DataValueSnapshot NotFound(DateTime atUtc)
{
var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, atUtc, atUtc);
Placeholders.AddOrUpdate(placeholder, PlaceholderMark);
return placeholder;
}
///
/// The start bound to return ahead of the data, or null when a stored sample sits on StartTime (that sample is
/// the bound, and it is already the data's first sample).
///
public static async Task StartBoundAsync(
IHistorianDataSource source, string tagname, DateTime startUtc, bool reverse, int maxTieCluster, DateTime nowUtc,
CancellationToken ct)
{
if (IsOpen(startUtc)) return NotFound(startUtc);
var found = reverse
? await EarliestAtOrAfterAsync(source, tagname, startUtc, nowUtc, ct).ConfigureAwait(false)
: await LatestAtOrBeforeAsync(source, tagname, startUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false);
if (found is null) return NotFound(startUtc);
return SameInstant(Ticks(found), startUtc.Ticks, Resolution(source)) ? null : found;
}
/// The end bound: the stored sample on or beyond the end time, else the placeholder.
/// The backend.
/// Historian tag.
/// The pending end bound.
/// The last value's time already returned (stamps an open bound's placeholder).
/// The walk-back's row budget (forward-only backends).
/// Now (the walk-back's look-ahead clamp).
/// Cancellation.
public static async Task EndBoundAsync(
IHistorianDataSource source, string tagname, HistoryEndBound bound, DateTime previousReturnedUtc,
int maxTieCluster, DateTime nowUtc, CancellationToken ct)
{
if (bound.Open)
{
var ticks = bound.Reverse
? Math.Max(0L, previousReturnedUtc.Ticks - OpenBoundStep.Ticks)
: Math.Min(DateTime.MaxValue.Ticks, previousReturnedUtc.Ticks + OpenBoundStep.Ticks);
return NotFound(new DateTime(ticks, DateTimeKind.Utc));
}
if (bound.Resolved is { } resolved) return resolved; // #692: an earlier page already looked it up
DataValueSnapshot? found;
if (bound.Reverse)
found = await LatestAtOrBeforeAsync(source, tagname, bound.AtUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false);
else if (bound.Inclusive)
found = await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc, nowUtc, ct).ConfigureAwait(false);
else
{
var resolution = Resolution(source);
found = bound.AtUtc.Ticks > DateTime.MaxValue.Ticks - resolution
? null
: await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc.AddTicks(resolution), nowUtc, ct).ConfigureAwait(false);
}
return found ?? NotFound(bound.AtUtc);
}
///
/// Whether a data sample lies inside the data window of a bounded read — a forward window excludes the end time (a
/// sample there is the end bound), compared at the storage resolution. A backend whose forward end is inclusive, or a
/// resumed read that steps onto the end time, would otherwise return the end bound twice.
///
public static bool InDataWindow(DataValueSnapshot sample, HistoryEndBound bound, long resolution)
{
if (!bound.Inclusive || bound.Open) return true;
var ts = Align(Ticks(sample), resolution);
var end = Align(bound.AtUtc.Ticks, resolution);
return bound.Reverse ? ts > end : ts < end;
}
/// Whether a resumed read's next start is already past the data window (only the end bound remains).
public static bool DataWindowDrained(DateTime nextStartUtc, HistoryEndBound bound, long resolution)
{
if (!bound.Inclusive || bound.Open) return false;
var next = Align(nextStartUtc.Ticks, resolution);
var end = Align(bound.AtUtc.Ticks, resolution);
return bound.Reverse ? next <= end : next >= end;
}
/// The latest stored sample at or before , or null.
internal static async Task LatestAtOrBeforeAsync(
IHistorianDataSource source, string tagname, DateTime atUtc, int maxTieCluster, DateTime nowUtc, CancellationToken ct)
{
if (atUtc.Ticks == 0) return null;
var floor = new DateTime(0, DateTimeKind.Utc);
IReadOnlyList rows = source.SupportsReverseRaw
? (await source.ReadRawAsync(tagname, atUtc, floor, 1, ct).ConfigureAwait(false)).Samples
: (await ReverseRawReader.ReadAsync(source, tagname, atUtc, floor, 1, maxTieCluster, nowUtc, 0, ct)
.ConfigureAwait(false)).Samples;
var limit = Align(atUtc.Ticks, Resolution(source));
foreach (var row in rows)
if (Align(Ticks(row), Resolution(source)) <= limit)
return row;
return null;
}
/// The earliest stored sample at or after , or null.
///
/// #691 review: on a backend that reads forward only (the Gateway) the read's end is clamped to now +
/// — the same horizon the walk-back uses — and a time beyond it is "not found"
/// without a read. The HistorianGateway only checks start <= end, so a DateTime.MaxValue end would
/// reach AVEVA as a year-9999 query end (unverified: a rejection, or a fabricated row). The clamp is NOT applied to a
/// backend that reads newest-first natively (SqlServer / QuestDB): they bind MaxValue safely (pinned by the
/// Extreme_bounds conformance test) and can legitimately hold samples further ahead (HistoryUpdate's
/// HistoryUpdateMaxFutureSkew is configurable, and driver-stamped live values carry device time) — clamping
/// there would turn a real end bound into Bad_BoundNotFound.
///
internal static async Task EarliestAtOrAfterAsync(
IHistorianDataSource source, string tagname, DateTime atUtc, DateTime nowUtc, CancellationToken ct)
{
var endTicks = DateTime.MaxValue.Ticks;
if (!source.SupportsReverseRaw)
endTicks = nowUtc.Ticks > DateTime.MaxValue.Ticks - ReverseRawReader.LookAhead.Ticks
? DateTime.MaxValue.Ticks
: nowUtc.Ticks + ReverseRawReader.LookAhead.Ticks;
if (atUtc.Ticks >= endTicks) return null;
var rows = (await source.ReadRawAsync(tagname, atUtc, new DateTime(endTicks, DateTimeKind.Utc), 1, ct)
.ConfigureAwait(false)).Samples;
var limit = Align(atUtc.Ticks, Resolution(source));
foreach (var row in rows)
if (Align(Ticks(row), Resolution(source)) >= limit)
return row;
return null;
}
private static bool IsOpen(DateTime t) => t.Ticks == 0 || t.Ticks == DateTime.MaxValue.Ticks;
private static bool SameInstant(long a, long b, long resolution) => Align(a, resolution) == Align(b, resolution);
private static long Resolution(IHistorianDataSource source) => Math.Max(1L, source.TimestampResolutionTicks);
private static long Ticks(DataValueSnapshot s) => (s.SourceTimestampUtc ?? DateTime.MinValue).Ticks;
private static long Align(long ticks, long resolution) => ticks - ticks % resolution;
}