using System.Globalization; using System.Text; using CsvHelper; using CsvHelper.Configuration; using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; namespace OTSSignsOrchestrator.Server.Reports; public class BillingReportService { private readonly IServiceProvider _services; private readonly ILogger _logger; public BillingReportService(IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } /// /// Generates a billing CSV for the given date range. /// Columns: customer_abbrev, company_name, date, screen_count, plan /// public async Task GenerateBillingCsvAsync(DateOnly from, DateOnly to) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var rows = await db.ScreenSnapshots .AsNoTracking() .Include(s => s.Instance) .ThenInclude(i => i.Customer) .Where(s => s.SnapshotDate >= from && s.SnapshotDate <= to) .Select(s => new { Abbrev = s.Instance.Customer.Abbreviation, CompanyName = s.Instance.Customer.CompanyName, Plan = s.Instance.Customer.Plan, s.SnapshotDate, s.ScreenCount, }) .ToListAsync(); // Group by customer abbreviation + date, sum screen counts across instances var grouped = rows .GroupBy(r => new { r.Abbrev, r.SnapshotDate }) .Select(g => new BillingCsvRow { CustomerAbbrev = g.Key.Abbrev, CompanyName = g.First().CompanyName, Date = g.Key.SnapshotDate, ScreenCount = g.Sum(r => r.ScreenCount), Plan = g.First().Plan.ToString(), }) .OrderBy(r => r.CustomerAbbrev) .ThenBy(r => r.Date) .ToList(); _logger.LogInformation("Generated billing CSV: {RowCount} rows for {From} to {To}", grouped.Count, from, to); return WriteCsv(grouped); } /// /// Generates a version drift CSV showing current vs latest Xibo version and OAuth credential age. /// Columns: abbrev, current_version, latest_version, credential_age_days /// public async Task GenerateVersionDriftCsvAsync() { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); // Latest health event per instance where check_name = "xibo-version" var versionEvents = await db.HealthEvents .AsNoTracking() .Include(h => h.Instance) .ThenInclude(i => i.Customer) .Where(h => h.CheckName == "xibo-version") .GroupBy(h => h.InstanceId) .Select(g => g.OrderByDescending(h => h.OccurredAt).First()) .ToListAsync(); // Latest OauthAppRegistry per instance var latestOauth = await db.OauthAppRegistries .AsNoTracking() .GroupBy(o => o.InstanceId) .Select(g => g.OrderByDescending(o => o.CreatedAt).First()) .ToDictionaryAsync(o => o.InstanceId); var rows = versionEvents.Select(h => { var parts = ParseVersionMessage(h.Message); latestOauth.TryGetValue(h.InstanceId, out var oauth); var credAgeDays = oauth is not null ? (int)(DateTime.UtcNow - oauth.CreatedAt).TotalDays : -1; return new VersionDriftCsvRow { Abbrev = h.Instance.Customer.Abbreviation, CurrentVersion = parts.Current, LatestVersion = parts.Latest, CredentialAgeDays = credAgeDays, }; }) .OrderBy(r => r.Abbrev) .ToList(); _logger.LogInformation("Generated version drift CSV: {RowCount} rows", rows.Count); return WriteCsv(rows); } private static byte[] WriteCsv(IEnumerable records) { using var stream = new MemoryStream(); using var writer = new StreamWriter(stream, Encoding.UTF8); using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true, }); csv.WriteRecords(records); writer.Flush(); return stream.ToArray(); } /// /// Parses the health event message to extract current/latest version strings. /// Expected format from XiboVersionHealthCheck: "Current: X.Y.Z, Latest: A.B.C" or similar. /// Returns ("unknown", "unknown") if parsing fails. /// private static (string Current, string Latest) ParseVersionMessage(string? message) { if (string.IsNullOrWhiteSpace(message)) return ("unknown", "unknown"); var current = "unknown"; var latest = "unknown"; // Try to extract "Current: X.Y.Z" pattern var currentIdx = message.IndexOf("Current:", StringComparison.OrdinalIgnoreCase); if (currentIdx >= 0) { var afterCurrent = message[(currentIdx + 8)..].Trim(); var end = afterCurrent.IndexOfAny([',', ' ', '\n']); current = end > 0 ? afterCurrent[..end].Trim() : afterCurrent.Trim(); } var latestIdx = message.IndexOf("Latest:", StringComparison.OrdinalIgnoreCase); if (latestIdx >= 0) { var afterLatest = message[(latestIdx + 7)..].Trim(); var end = afterLatest.IndexOfAny([',', ' ', '\n']); latest = end > 0 ? afterLatest[..end].Trim() : afterLatest.Trim(); } return (current, latest); } } internal sealed class BillingCsvRow { public string CustomerAbbrev { get; set; } = string.Empty; public string CompanyName { get; set; } = string.Empty; public DateOnly Date { get; set; } public int ScreenCount { get; set; } public string Plan { get; set; } = string.Empty; } internal sealed class VersionDriftCsvRow { public string Abbrev { get; set; } = string.Empty; public string CurrentVersion { get; set; } = string.Empty; public string LatestVersion { get; set; } = string.Empty; public int CredentialAgeDays { get; set; } }