using System.Globalization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
///
/// Push-down QuestDB SQL for processed (aggregate) reads (spec §3 "Processed", T0 Q8-confirmed forms), one query
/// per , mirroring the SQL Server backend's SqlAggregateQueries family split and
/// column order. Every query takes five positional parameters:
///
/// - $1 — the (rounded) window start in 100 ns ticks since the Unix epoch
/// (, Bigint);
/// - $2 — the bucket width in ticks (, Bigint);
/// - $3 — the tag (Varchar);
/// - $4/$5 — the forward window [start, end) (TimestampTz via
/// ).
///
/// Each returns one row per non-empty bucket; the caller fills the empty ones.
///
///
///
/// Bucketing. b = (cast(ts AS LONG) * 10 − $1) / $2. cast(ts AS LONG) is µs since 1970, so
/// × 10 is ticks since 1970; both it and $1 are µs-aligned (R6), so the integer division is
/// start-inclusive / end-exclusive to the tick, exactly like SQL Server's. Every row is at or after the start,
/// so the dividend is never negative (no truncation-toward-zero surprise), and nothing overflows before 9999.
/// SAMPLE BY is deliberately not used: its alignment rules differ from ours.
///
///
/// Sample classes (S6). The status is stored as the unsigned 32-bit code in a LONG, so
/// "not Bad" is status < 0x80000000 (not T-SQL's sign test). usable = not Bad AND
/// kind <> 0 (an Empty sample is an outage marker and counts as Bad whatever its code);
/// good = usable AND status < 0x40000000; bad = not usable; non-numeric = usable
/// String/DateTime. The literals are generated from and .
///
/// Common result columns (ordinals 0–5): b, total_count, usable_count, good_count, bad_count,
/// non_numeric_count. Family columns follow from .
///
internal static class QuestDbAggregateQueries
{
/// Ordinal of the first family-specific column.
public const int FirstFamilyColumn = 6;
/// status < SeverityBadBit, as a decimal LONG literal.
internal static readonly string NotBadLimit = StatusBits.SeverityBadBit.ToString(CultureInfo.InvariantCulture);
/// status < SeverityUncertainBit, as a decimal LONG literal.
internal static readonly string GoodLimit = StatusBits.SeverityUncertainBit.ToString(CultureInfo.InvariantCulture);
/// The kind values of String and DateTime: usable but not numeric.
internal static readonly string NonNumericKinds =
((byte)HistorianValueKind.String).ToString(CultureInfo.InvariantCulture) + ", " +
((byte)HistorianValueKind.DateTime).ToString(CultureInfo.InvariantCulture);
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
///
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
/// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value (DOUBLE, NULL when no usable sample).
///
/// Prefixed sample table name.
/// The SQL text.
public static string Simple(string sample) =>
"SELECT b, " + CommonCounts() + ", " +
"avg(CASE WHEN u = 1 THEN num_value END) AS avg_value, " +
"min(CASE WHEN u = 1 THEN num_value END) AS min_value, " +
"max(CASE WHEN u = 1 THEN num_value END) AS max_value, " +
"sum(CASE WHEN u = 1 THEN num_value END) AS sum_value " +
"FROM (" + Classified(sample) + ") GROUP BY b ORDER BY b";
///
/// Start / End / Delta: the first and last usable sample of each bucket by ts.
/// Family columns: 6..10 first sample (kind, num_value, int_value, str_value, ts), 11..15 last
/// sample (same order). A bucket without a usable sample has NULL ts but kind = 0 (BYTE has no NULL),
/// so the reader keys absence on the NULL time.
///
/// Prefixed sample table name.
/// The SQL text.
public static string StartEnd(string sample) =>
"WITH s AS (" + Classified(sample) + "), " +
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
"f AS (SELECT b, kind, num_value, int_value, str_value, ts, " +
"row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " +
"row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s WHERE u = 1) " +
"SELECT st.*, " +
"x.kind, x.num_value, x.int_value, x.str_value, x.ts, " +
"y.kind, y.num_value, y.int_value, y.str_value, y.ts " +
"FROM st LEFT JOIN (SELECT * FROM f WHERE rf = 1) x ON x.b = st.b " +
"LEFT JOIN (SELECT * FROM f WHERE rl = 1) y ON y.b = st.b ORDER BY st.b";
///
/// MinimumActualTime / MaximumActualTime: the usable sample with the smallest (largest) num_value, ties
/// broken by the earliest ts. Family columns: 6 num_value, 7 ts (NULL when the bucket has no usable
/// numeric sample).
///
/// Prefixed sample table name.
/// True for MaximumActualTime.
/// The SQL text.
public static string ActualTime(string sample, bool maximum) =>
"WITH s AS (" + Classified(sample) + "), " +
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
"r AS (SELECT b, num_value, ts, " +
"row_number() OVER (PARTITION BY b ORDER BY num_value " + (maximum ? "DESC" : "ASC") + ", ts ASC) AS rn " +
"FROM s WHERE u = 1 AND num_value IS NOT NULL) " +
"SELECT st.*, r.num_value, r.ts " +
"FROM st LEFT JOIN (SELECT * FROM r WHERE rn = 1) r ON r.b = st.b ORDER BY st.b";
///
/// Time-weighted family (#671), bounds statement — not pushed down (see ).
/// Four LIMIT 1 seeks UNION ALL-ed, each tagged with k: 0 = latest usable sample before the
/// start, 1 = latest sample of any quality before the start, 2 = first sample of any quality at or after the end,
/// 3 = first usable one at or after the end. Parameters: $1 tag, $2/$3 the forward window
/// start / end. Columns: k, then ts, kind, num_value, int_value, str_value, status, server_ts.
///
/// Prefixed sample table name.
/// The SQL text.
public static string TimeWeightedBounds(string sample)
{
var usable = $" AND status < {NotBadLimit} AND kind <> {EmptyKind}";
string Seek(int k, string cmp, string filter, string order) =>
$"SELECT * FROM (SELECT {k.ToString(CultureInfo.InvariantCulture)} AS k, {SampleColumns} FROM {sample} " +
$"WHERE tag = $1 AND ts {cmp}{filter} AND NOT deleted ORDER BY ts {order} LIMIT 1)";
return Seek(0, "< $2", usable, "DESC") + " UNION ALL " + Seek(1, "< $2", "", "DESC") + " UNION ALL " +
Seek(2, ">= $3", "", "ASC") + " UNION ALL " + Seek(3, ">= $3", usable, "ASC");
}
///
/// Time-weighted family (#671), window statement: the live samples in [$2, $3) for tag $1, ascending,
/// at most $4 (the raw-row cap + 1).
/// Columns: ts, kind, num_value, int_value, str_value, status, server_ts. The caller feeds the before-bounds,
/// these rows and the after-bounds, in that order, into — the same evaluator
/// the SQL Server backend uses, so the two agree exactly. Trade-off: the window's rows travel to the server rather
/// than one row per bucket; Part 13's segment interpolation and bucket-edge splitting are then written once
/// instead of twice in two SQL dialects that must stay bit-identical.
///
/// Prefixed sample table name.
/// The SQL text.
public static string TimeWeightedWindow(string sample) =>
$"SELECT {SampleColumns} FROM {sample} WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts ASC LIMIT $4";
/// The row columns the time-weighted statements return (after k for the bounds).
private const string SampleColumns = "ts, kind, num_value, int_value, str_value, status, server_ts";
/// The bucket-index expression (exposed for tests).
internal const string BucketExpression = "(cast(ts AS LONG) * 10 - $1) / $2";
/// The classified subquery s: the window's live samples with bucket index and the usable/good flags.
private static string Classified(string sample) =>
"SELECT " + BucketExpression + " AS b, ts, kind, num_value, int_value, str_value, " +
$"CASE WHEN status < {NotBadLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS u, " +
$"CASE WHEN status < {GoodLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS g " +
$"FROM {sample} WHERE tag = $3 AND ts >= $4 AND ts < $5 AND NOT deleted";
/// The five common count columns (after b).
private static string CommonCounts() =>
"count() AS total_count, sum(u) AS usable_count, sum(g) AS good_count, count() - sum(u) AS bad_count, " +
$"sum(CASE WHEN u = 1 AND kind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS non_numeric_count";
}