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!) : []; } }