using System.Globalization;
using System.Text;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace MeterVault.App.Analysis;
/// The URL keys of the analysis state (D-02, D-46, D-47). Stable invariant identifiers, never localized.
public static class AnalysisUrlKeys
{
public const string Scope = "scope";
public const string Id = "id";
public const string Ids = "ids";
public const string Metric = "metric";
public const string Period = "period";
public const string From = "from";
public const string To = "to";
public const string Bucket = "bucket";
public const string Compare = "compare";
/// Every analysis key, in the order links write them.
public static IReadOnlyList All { get; } = [Scope, Id, Ids, Metric, Period, From, To, Bucket, Compare];
}
/// Which parts of an a URL is written with.
[Flags]
public enum AnalysisQueryParts
{
None = 0,
/// scope, id, ids.
Scope = 1,
/// metric.
Metric = 2,
/// period, from, to.
Period = 4,
/// bucket.
Bucket = 8,
/// compare.
Comparison = 16,
/// What a link carries onward to another page (D-47): everything but the scope, which the target's route names.
Carry = Metric | Period | Bucket | Comparison,
All = Scope | Carry,
}
///
/// The analysis state of a page, parsed from its URL (D-02, D-46, D-47, brief §4.1): period preset (or custom dates),
/// bucket size, comparison, metric and scope — immutable and compared by value, so a page reloads its analysis only
/// when this value changes.
///
///
///
/// Reading. reads the keys of . A key
/// that is absent takes the page default (); a key with an invalid value takes the default
/// too and adds a notice — a hand-edited or stale link never breaks the page (D-02). Tokens
/// are read case-insensitively; previous-year / previous-period are accepted for prev-year /
/// prev-period. from/to (yyyy-MM-dd, inclusive) make a custom range when period is
/// custom or absent, and are ignored beside another preset. Explicit meter selections keep at most
/// meters.
///
///
/// Writing. , and
/// write the canonical tokens and omit every key equal to the target page's default; a custom range is written
/// as from and to alone. Links to another page carry (period,
/// bucket, comparison, metric), because the target's route names its scope.
///
///
/// Resolving. turns the preset into a through
/// , once per load, against a captured now and the instance zone; all spans the
/// scope's availability (D-19). , and
/// build the reader requests in one place, so pages and the CSV export ask identically.
///
///
/// are not part of the value: two URLs that resolve to the same state are equal, whatever was
/// wrong with them. The With… helpers return a query without notices — a choice made in the toolbar is clean.
///
///
public sealed class AnalysisQuery : IEquatable
{
private AnalysisQuery(
PeriodPreset period,
DateOnly? from,
DateOnly? to,
BucketSize bucket,
ComparisonRequest comparison,
AnalysisMetric? metric,
QueryScope scope,
IReadOnlyList notices)
{
Period = period;
From = from;
To = to;
Bucket = bucket;
Comparison = comparison;
Metric = metric;
Scope = scope;
Notices = notices;
}
/// The period preset; with /.
public PeriodPreset Period { get; }
/// The first local day of a custom range (inclusive); null for a preset.
public DateOnly? From { get; }
/// The last local day of a custom range (inclusive); null for a preset.
public DateOnly? To { get; }
public BucketSize Bucket { get; }
public ComparisonRequest Comparison { get; }
/// The chosen metric; null for the scope's natural one (the page decides).
public AnalysisMetric? Metric { get; }
public QueryScope Scope { get; }
/// What in the URL was not used as written; not part of equality.
public IReadOnlyList Notices { get; }
public bool IsCustom => Period == PeriodPreset.Custom;
/// The page defaults as a query: what a page shows with no analysis keys in its URL.
public static AnalysisQuery Default(AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(defaults);
return new AnalysisQuery(defaults.Period, null, null, defaults.Bucket, defaults.Comparison, defaults.Metric, defaults.Scope, []);
}
///
/// Parses the analysis keys of a URL — absolute (),
/// base-relative, or a query string starting with ?. Anything without a ? has no keys.
///
public static AnalysisQuery Parse(string? uriOrQuery, AnalysisDefaults defaults) =>
Parse(QueryHelpers.ParseQuery(QueryOf(uriOrQuery)), defaults);
/// Parses the analysis keys of .
public static AnalysisQuery Parse(Uri uri, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(uri);
return Parse(uri.IsAbsoluteUri ? uri.Query : uri.OriginalString, defaults);
}
/// Parses the analysis keys of a query collection (, a parsed query).
public static AnalysisQuery Parse(IEnumerable> query, AnalysisDefaults defaults)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentNullException.ThrowIfNull(defaults);
var keys = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var (key, values) in query)
{
if (key is not null)
{
keys[key] = keys.TryGetValue(key, out var existing) ? StringValues.Concat(existing, values) : values;
}
}
var reader = new Reader(keys);
var notices = new List();
var (period, from, to) = ParsePeriod(reader, defaults, notices);
var bucket = ParseToken(reader, AnalysisUrlKeys.Bucket, defaults.Bucket, AnalysisTokens.TryParseBucket, AnalysisQueryNoticeKind.InvalidBucket, notices);
var comparison = ParseComparison(reader, defaults, notices);
var metric = ParseMetric(reader, defaults, notices);
var scope = ParseScope(reader, defaults, notices);
return new AnalysisQuery(period, from, to, bucket, comparison, metric, scope, notices);
}
/// This query with a preset period.
/// : use .
public AnalysisQuery WithPeriod(PeriodPreset preset)
{
if (preset == PeriodPreset.Custom || !Enum.IsDefined(preset))
{
throw new ArgumentException("A custom period needs its dates; use WithCustomRange.", nameof(preset));
}
return new AnalysisQuery(preset, null, null, Bucket, Comparison, Metric, Scope, []);
}
/// This query with a custom range of local days, both inclusive.
/// The range fails : check it first (the toolbar applies a range only once it is valid).
public AnalysisQuery WithCustomRange(DateOnly first, DateOnly last)
{
if (!PeriodResolver.IsValidCustomRange(first, last))
{
throw new ArgumentException("A custom range needs a first and last day in order, within the supported dates.", nameof(first));
}
return new AnalysisQuery(PeriodPreset.Custom, first, last, Bucket, Comparison, Metric, Scope, []);
}
public AnalysisQuery WithBucket(BucketSize bucket) =>
Enum.IsDefined(bucket)
? new AnalysisQuery(Period, From, To, bucket, Comparison, Metric, Scope, [])
: throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Unknown bucket size.");
/// A comparison without its year, which no URL can hold.
public AnalysisQuery WithComparison(ComparisonRequest comparison)
{
ArgumentNullException.ThrowIfNull(comparison);
if (comparison.Kind == ComparisonKind.Year && comparison.Year is null)
{
throw new ArgumentException("A year comparison needs its year.", nameof(comparison));
}
return new AnalysisQuery(Period, From, To, Bucket, comparison, Metric, Scope, []);
}
/// This query with a metric; null for the scope's natural one.
public AnalysisQuery WithMetric(AnalysisMetric? metric) => new(Period, From, To, Bucket, Comparison, metric, Scope, []);
public AnalysisQuery WithScope(QueryScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
return new AnalysisQuery(Period, From, To, Bucket, Comparison, Metric, scope, []);
}
///
/// The URL parameters of this query, in canonical order and tokens, leaving out every key equal to
/// (the target page's) and every part not in .
///
public IReadOnlyList> ToQueryParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
{
ArgumentNullException.ThrowIfNull(defaults);
var list = new List>(6);
if (parts.HasFlag(AnalysisQueryParts.Scope) && !Scope.Equals(defaults.Scope))
{
list.Add(new(AnalysisUrlKeys.Scope, Scope.Token));
if (Scope.Kind == QueryScopeKind.Meters)
{
list.Add(new(AnalysisUrlKeys.Ids, string.Join(',', Scope.MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture)))));
}
else if (Scope.Id is { } id)
{
list.Add(new(AnalysisUrlKeys.Id, id.ToString(CultureInfo.InvariantCulture)));
}
}
if (parts.HasFlag(AnalysisQueryParts.Metric) && Metric is { } metric && metric != defaults.Metric)
{
list.Add(new(AnalysisUrlKeys.Metric, AnalysisMetrics.Format(metric)));
}
if (parts.HasFlag(AnalysisQueryParts.Period))
{
if (IsCustom)
{
// from/to imply custom, so the preset key is left out.
list.Add(new(AnalysisUrlKeys.From, AnalysisTokens.FormatDate(From!.Value)));
list.Add(new(AnalysisUrlKeys.To, AnalysisTokens.FormatDate(To!.Value)));
}
else if (Period != defaults.Period)
{
list.Add(new(AnalysisUrlKeys.Period, AnalysisTokens.Format(Period)));
}
}
if (parts.HasFlag(AnalysisQueryParts.Bucket) && Bucket != defaults.Bucket)
{
list.Add(new(AnalysisUrlKeys.Bucket, AnalysisTokens.Format(Bucket)));
}
if (parts.HasFlag(AnalysisQueryParts.Comparison) && !Comparison.Equals(defaults.Comparison))
{
list.Add(new(AnalysisUrlKeys.Compare, AnalysisTokens.Format(Comparison)));
}
return list;
}
///
/// Every analysis key of for
/// :
/// the value to write, or null to remove a key that equals the default — so updating the current page's URL keeps
/// its other keys (tab) and drops stale ones (from/to after leaving a custom range).
///
public IReadOnlyDictionary ToNavigationParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All)
{
var result = new Dictionary(StringComparer.Ordinal);
foreach (var key in KeysOf(parts))
{
result[key] = null;
}
foreach (var (key, value) in ToQueryParameters(defaults, parts))
{
result[key] = value;
}
return result;
}
///
/// with this query's parameters appended after its own (D-47: existing keys first), leaving
/// out what equals — the target page's. Links carry
/// by default: the target's route names its own scope.
///
public string AppendTo(string url, AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.Carry)
{
ArgumentNullException.ThrowIfNull(url);
var parameters = ToQueryParameters(defaults, parts);
if (parameters.Count == 0)
{
return url;
}
var builder = new StringBuilder(url);
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
foreach (var (key, value) in parameters)
{
builder.Append(separator).Append(key).Append('=').Append(Escape(value));
separator = '&';
}
return builder.ToString();
}
///
/// Resolves the period once, against the captured in the instance
/// (D-01, D-03). all spans (D-19) — the quantity or cost scope's, whichever
/// the page shows — and is the "no history" range without it.
///
public ResolvedPeriod Resolve(DateTimeOffset now, TimeZoneInfo zone, AvailableRange? availability = null)
{
ArgumentNullException.ThrowIfNull(zone);
return Period switch
{
PeriodPreset.Custom => PeriodResolver.Resolve(PeriodPreset.Custom, From, To, now, zone),
PeriodPreset.AllHistory => PeriodResolver.Resolve(
PeriodPreset.AllHistory, null, null, now, zone, availability?.FirstDay, availability?.LastDay),
_ => PeriodResolver.Resolve(Period, null, null, now, zone),
};
}
///
/// The quantity request of this query over (bucket and comparison included), or null for
/// a category scope, which is analysed by cost.
///
/// The period from .
/// For a type or portfolio: also one series per meter ("individual meters").
public AnalysisRequest? ToAnalysisRequest(ResolvedPeriod period, bool includeMeterSeries = false)
{
ArgumentNullException.ThrowIfNull(period);
return Scope.ToAnalysisScope() is { } scope
? new AnalysisRequest(scope, period) { Bucket = Bucket, Comparison = Comparison, IncludeMeterSeries = includeMeterSeries }
: null;
}
///
/// The cost requests of this query over : one for the portfolio, a type, a meter or a
/// category, one per meter for a selection.
///
/// The period from .
///
/// Buckets to price in — a quantity result's , or the first cost result's for the
/// rest of a selection — so cost and quantity share their buckets; null lets the cost reader plan from
/// .
///
/// For the portfolio: also the category composition (D-42).
public IReadOnlyList ToCostRequests(ResolvedPeriod period, BucketPlan? plan = null, bool includeCategories = false)
{
ArgumentNullException.ThrowIfNull(period);
return
[
.. Scope.ToCostScopes().Select(scope => new CostAnalysisRequest(scope, period)
{
Bucket = Bucket,
Plan = plan,
IncludeCategories = includeCategories && scope.Kind == CostScopeKind.Portfolio,
}),
];
}
///
/// The cost request for this query's comparison (D-06) of an already priced request: the
/// comparison period, priced in the images of the current buckets (), so
/// bucket i of the result compares with bucket i of the current one. The cost reader has no comparison of its own;
/// quantities get theirs from the analysis reader ().
///
/// The current cost request (its scope and period).
/// The plan the current result was priced in ().
public CostComparisonRequest ToCostComparison(CostAnalysisRequest current, BucketPlan currentPlan)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(currentPlan);
var resolution = ComparisonResolver.Resolve(current.Period, Comparison);
if (!resolution.IsApplicable)
{
return new CostComparisonRequest(resolution, null, []);
}
var pairs = ComparisonResolver.PairBuckets(current.Period, resolution.Period, currentPlan.Buckets);
var plan = new BucketPlan(currentPlan.Size, currentPlan.Size, [.. pairs.Select(p => p.Comparison)], pairs.Count, Refused: false, Suggested: null);
var request = new CostAnalysisRequest(current.Scope, resolution.Period.ToResolvedPeriod(current.Period))
{
Plan = plan,
MaxPoints = current.MaxPoints,
IncludeCategories = current.IncludeCategories,
};
return new CostComparisonRequest(resolution, request, pairs);
}
public bool Equals(AnalysisQuery? other) =>
other is not null
&& Period == other.Period
&& From == other.From
&& To == other.To
&& Bucket == other.Bucket
&& Comparison.Equals(other.Comparison)
&& Metric == other.Metric
&& Scope.Equals(other.Scope);
public override bool Equals(object? obj) => Equals(obj as AnalysisQuery);
public override int GetHashCode() => HashCode.Combine(Period, From, To, Bucket, Comparison, Metric, Scope);
public static bool operator ==(AnalysisQuery? left, AnalysisQuery? right) => left is null ? right is null : left.Equals(right);
public static bool operator !=(AnalysisQuery? left, AnalysisQuery? right) => !(left == right);
/// Every key written, defaults included — for logs.
public override string ToString()
{
var period = IsCustom
? AnalysisTokens.FormatDate(From!.Value) + ".." + AnalysisTokens.FormatDate(To!.Value)
: AnalysisTokens.Format(Period);
var metric = Metric is { } m ? AnalysisMetrics.Format(m) : "natural";
return string.Create(
CultureInfo.InvariantCulture,
$"{Scope} {metric} {period} {AnalysisTokens.Format(Bucket)} {AnalysisTokens.Format(Comparison)}");
}
private static (PeriodPreset Period, DateOnly? From, DateOnly? To) ParsePeriod(Reader reader, AnalysisDefaults defaults, List notices)
{
var periodToken = reader.First(AnalysisUrlKeys.Period);
var fromToken = reader.First(AnalysisUrlKeys.From);
var toToken = reader.First(AnalysisUrlKeys.To);
if (periodToken is not null)
{
if (!AnalysisTokens.TryParsePeriod(periodToken, out var preset))
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidPeriod, AnalysisUrlKeys.Period, periodToken));
return (defaults.Period, null, null);
}
// Beside another preset, from/to mean nothing and are ignored.
if (preset != PeriodPreset.Custom)
{
return (preset, null, null);
}
}
else if (fromToken is null && toToken is null)
{
return (defaults.Period, null, null);
}
if (AnalysisTokens.TryParseCustomRange(fromToken, toToken, out var first, out var last))
{
return (PeriodPreset.Custom, first, last);
}
notices.Add(new AnalysisQueryNotice(
AnalysisQueryNoticeKind.InvalidRange, AnalysisUrlKeys.From + "/" + AnalysisUrlKeys.To, (fromToken ?? string.Empty) + "/" + (toToken ?? string.Empty)));
return (defaults.Period, null, null);
}
private static ComparisonRequest ParseComparison(Reader reader, AnalysisDefaults defaults, List notices)
{
var token = reader.First(AnalysisUrlKeys.Compare);
if (token is null)
{
return defaults.Comparison;
}
if (AnalysisTokens.TryParseComparison(token, out var comparison))
{
return comparison;
}
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidComparison, AnalysisUrlKeys.Compare, token));
return defaults.Comparison;
}
private static AnalysisMetric? ParseMetric(Reader reader, AnalysisDefaults defaults, List notices)
{
var token = reader.First(AnalysisUrlKeys.Metric);
if (token is null)
{
return defaults.Metric;
}
if (AnalysisMetrics.TryParse(token, out var metric))
{
return metric;
}
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidMetric, AnalysisUrlKeys.Metric, token));
return defaults.Metric;
}
private delegate bool TokenParser(string? token, out T value);
private static T ParseToken(
Reader reader, string key, T fallback, TokenParser parse, AnalysisQueryNoticeKind invalid, List notices)
{
var token = reader.First(key);
if (token is null)
{
return fallback;
}
if (parse(token, out var value))
{
return value;
}
notices.Add(new AnalysisQueryNotice(invalid, key, token));
return fallback;
}
private static QueryScope ParseScope(Reader reader, AnalysisDefaults defaults, List notices)
{
// Without a scope key, ids mean nothing: the route (or the page default) names the scope.
var token = reader.First(AnalysisUrlKeys.Scope);
if (token is null)
{
return defaults.Scope;
}
if (!QueryScope.TryParseKind(token, out var kind))
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token));
return defaults.Scope;
}
switch (kind)
{
case QueryScopeKind.Portfolio:
return QueryScope.Portfolio;
case QueryScopeKind.Meters:
return ParseSelection(reader, token, notices) ?? defaults.Scope;
default:
var idToken = reader.First(AnalysisUrlKeys.Id);
if (TryParseId(idToken, out var id))
{
return kind switch
{
QueryScopeKind.EnergyType => QueryScope.ForEnergyType(id),
QueryScopeKind.Category => QueryScope.ForCategory(id),
_ => QueryScope.ForMeter(id),
};
}
notices.Add(idToken is null
? new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token)
: new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Id, idToken));
return defaults.Scope;
}
}
///
/// ids=3,5,9 (or repeated ids, or a single id): the valid ids in order, distinct, at most
/// ; null when none is valid.
///
private static QueryScope? ParseSelection(Reader reader, string scopeToken, List notices)
{
var tokens = reader.All(AnalysisUrlKeys.Ids)
.SelectMany(v => v.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
.ToList();
if (tokens.Count == 0 && reader.First(AnalysisUrlKeys.Id) is { } single)
{
tokens.Add(single.Trim());
}
var ids = new List();
var invalid = new List();
foreach (var item in tokens)
{
if (!TryParseId(item, out var id))
{
invalid.Add(item);
}
else if (!ids.Contains(id))
{
ids.Add(id);
}
}
if (invalid.Count > 0)
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Ids, string.Join(',', invalid)));
}
if (ids.Count > AnalysisLimits.MaxSeries)
{
notices.Add(new AnalysisQueryNotice(
AnalysisQueryNoticeKind.TooManyMeters, AnalysisUrlKeys.Ids, ids.Count.ToString(CultureInfo.InvariantCulture)));
ids = ids.Take(AnalysisLimits.MaxSeries).ToList();
}
if (ids.Count == 0)
{
if (invalid.Count == 0)
{
notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, scopeToken));
}
return null;
}
return QueryScope.ForMeters(ids);
}
private static bool TryParseId(string? token, out int id)
{
id = 0;
return token is not null
&& int.TryParse(token.AsSpan().Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out id)
&& id > 0;
}
private static IEnumerable KeysOf(AnalysisQueryParts parts)
{
if (parts.HasFlag(AnalysisQueryParts.Scope))
{
yield return AnalysisUrlKeys.Scope;
yield return AnalysisUrlKeys.Id;
yield return AnalysisUrlKeys.Ids;
}
if (parts.HasFlag(AnalysisQueryParts.Metric))
{
yield return AnalysisUrlKeys.Metric;
}
if (parts.HasFlag(AnalysisQueryParts.Period))
{
yield return AnalysisUrlKeys.Period;
yield return AnalysisUrlKeys.From;
yield return AnalysisUrlKeys.To;
}
if (parts.HasFlag(AnalysisQueryParts.Bucket))
{
yield return AnalysisUrlKeys.Bucket;
}
if (parts.HasFlag(AnalysisQueryParts.Comparison))
{
yield return AnalysisUrlKeys.Compare;
}
}
/// The query part of a URL (from its ?, without a fragment), or empty.
private static string QueryOf(string? uriOrQuery)
{
if (string.IsNullOrEmpty(uriOrQuery))
{
return string.Empty;
}
var start = uriOrQuery.IndexOf('?', StringComparison.Ordinal);
if (start < 0)
{
return string.Empty;
}
var end = uriOrQuery.IndexOf('#', start);
return end < 0 ? uriOrQuery[start..] : uriOrQuery[start..end];
}
/// Escapes a value, keeping the : of year:2025 and the commas of an id list readable.
private static string Escape(string value) =>
Uri.EscapeDataString(value).Replace("%3A", ":", StringComparison.Ordinal).Replace("%2C", ",", StringComparison.Ordinal);
/// Case-insensitive access to a parsed query: the first non-blank value of a key, or all of them.
private sealed class Reader(Dictionary keys)
{
public string? First(string key)
{
if (!keys.TryGetValue(key, out var values))
{
return null;
}
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
public IEnumerable All(string key) =>
keys.TryGetValue(key, out var values) ? values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!) : [];
}
}