< Summary

Information
Class: Orchestrator.Commands.Observability.Cost.CostCommand.CostReport
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/Cost/CostCommand.cs
Line coverage
100%
Covered lines: 5
Uncovered lines: 0
Coverable lines: 5
Total lines: 774
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/Cost/CostCommand.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Spectre.Console.Cli;
 3using Spectre.Console;
 4using System.Globalization;
 5using System.Text.Json;
 6using System.Text.Json.Serialization;
 7using EHonda.KicktippAi.Core;
 8using Orchestrator.Infrastructure.Factories;
 9
 10namespace Orchestrator.Commands.Observability.Cost;
 11
 12public class CostCommand : AsyncCommand<CostSettings>
 13{
 14    private const int FirestoreInFilterLimit = 30;
 15
 16    private static readonly JsonSerializerOptions OutputJsonOptions = new(JsonSerializerDefaults.Web)
 17    {
 18        WriteIndented = true,
 19        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 20    };
 21
 22    private readonly IAnsiConsole _console;
 23    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 24    private readonly ILogger<CostCommand> _logger;
 25
 26    public CostCommand(
 27        IAnsiConsole console,
 28        IFirebaseServiceFactory firebaseServiceFactory,
 29        ILogger<CostCommand> logger)
 30    {
 31        _console = console;
 32        _firebaseServiceFactory = firebaseServiceFactory;
 33        _logger = logger;
 34    }
 35
 36    protected override async Task<int> ExecuteAsync(CommandContext context, CostSettings settings, CancellationToken can
 37    {
 38
 39        try
 40        {
 41            // Load configuration from file if specified
 42            if (!string.IsNullOrWhiteSpace(settings.ConfigFile))
 43            {
 44                var fileConfig = await LoadConfigurationFromFile(settings.ConfigFile);
 45                settings = MergeConfigurations(fileConfig, settings);
 46            }
 47
 48            // Create Firebase services using factory (factory handles env var loading)
 49            var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 50
 51            _console.MarkupLine($"[green]Cost command initialized[/]");
 52
 53            if (settings.Verbose)
 54            {
 55                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 56            }
 57
 58            if (settings.All)
 59            {
 60                _console.MarkupLine("[blue]All mode enabled - aggregating over all available data[/]");
 61            }
 62
 63            // Parse filter parameters
 64            var matchdays = ParseMatchdays(settings);
 65            var models = ParseModels(settings);
 66            var reasoningEfforts = ParseReasoningEfforts(settings);
 67            var communityContexts = ParseCommunityContexts(settings);
 68
 69            // Get available model configurations and community contexts if not specified
 70            var availableModelConfigs = await ResolveModelConfigsAsync(predictionRepository, models, reasoningEfforts, c
 71            var availableCommunityContexts = communityContexts ?? await predictionRepository.GetAvailableCommunityContex
 72            var queryMatchdays = matchdays;
 73
 74            if (settings.Verbose)
 75            {
 76                _console.MarkupLine($"[dim]Filters:[/]");
 77                _console.MarkupLine($"[dim]  Matchdays: {FormatMatchdayFilter(queryMatchdays)}[/]");
 78                _console.MarkupLine($"[dim]  Model Configs: {(models?.Any() == true || reasoningEfforts?.Any() == true ?
 79                _console.MarkupLine($"[dim]  Community Contexts: {(communityContexts?.Any() == true ? string.Join(", ", 
 80                _console.MarkupLine($"[dim]  Include Bonus: {settings.Bonus || settings.All}[/]");
 81            }
 82
 83            // Calculate costs
 84            var totalCost = 0.0;
 85            var matchPredictionCost = 0.0;
 86            var bonusPredictionCost = 0.0;
 87            var matchPredictionCount = 0;
 88            var bonusPredictionCount = 0;
 89
 90            // Structure to store detailed breakdown data with reprediction index support
 91            var costRows = new List<CostReportRow>();
 92
 93            await _console.Status()
 94                .Spinner(Spinner.Known.Dots)
 95                .StartAsync("Calculating costs...", async ctx =>
 96                {
 97                    foreach (var modelConfig in availableModelConfigs)
 98                    {
 99                        foreach (var communityContext in availableCommunityContexts)
 100                        {
 101                            ctx.Status($"Processing {modelConfig.DisplayName} - {communityContext}...");
 102
 103                            if (settings.Verbose)
 104                            {
 105                                _console.MarkupLine($"[dim]  Processing model config: {modelConfig.DisplayName}, communi
 106                            }
 107
 108                            // Get match prediction costs by reprediction index
 109                            var matchCostsByIndex = await GetMatchCostsByRepredictionIndexAsync(
 110                                predictionRepository,
 111                                modelConfig,
 112                                communityContext,
 113                                queryMatchdays,
 114                                cancellationToken);
 115
 116                            foreach (var kvp in matchCostsByIndex)
 117                            {
 118                                var repredictionIndex = kvp.Key;
 119                                var (cost, count) = kvp.Value;
 120
 121                                matchPredictionCost += cost;
 122                                matchPredictionCount += count;
 123
 124                                // Store detailed data for breakdown
 125                                if (cost > 0 || count > 0)
 126                                {
 127                                    costRows.Add(new CostReportRow(communityContext, modelConfig.Model, modelConfig.Reas
 128                                }
 129
 130                                if (settings.Verbose && (cost > 0 || count > 0))
 131                                {
 132                                    _console.MarkupLine($"[dim]    Match predictions (reprediction {repredictionIndex}):
 133                                }
 134                            }
 135
 136                            // Get bonus prediction costs if requested or if all mode is enabled
 137                            if (settings.Bonus || settings.All)
 138                            {
 139                                var bonusCostsByIndex = await predictionRepository.GetBonusPredictionCostsByReprediction
 140                                    modelConfig,
 141                                    communityContext,
 142                                    cancellationToken);
 143
 144                                foreach (var kvp in bonusCostsByIndex)
 145                                {
 146                                    var repredictionIndex = kvp.Key;
 147                                    var (cost, count) = kvp.Value;
 148
 149                                    bonusPredictionCost += cost;
 150                                    bonusPredictionCount += count;
 151
 152                                    // Store detailed data for breakdown
 153                                    if (cost > 0 || count > 0)
 154                                    {
 155                                        costRows.Add(new CostReportRow(communityContext, modelConfig.Model, modelConfig.
 156                                    }
 157
 158                                    if (settings.Verbose && (cost > 0 || count > 0))
 159                                    {
 160                                        _console.MarkupLine($"[dim]    Bonus predictions (reprediction {repredictionInde
 161                                    }
 162                                }
 163                            }
 164                        }
 165                    }
 166                });
 167
 168            totalCost = matchPredictionCost + bonusPredictionCost;
 169
 170            // Display results
 171            var table = new Table();
 172            table.Border(TableBorder.Rounded);
 173
 174            if (settings.DetailedBreakdown)
 175            {
 176                // Add columns for detailed breakdown with reprediction support
 177                table.AddColumn("Community Context");
 178                table.AddColumn("Model");
 179                table.AddColumn("Category");
 180                table.AddColumn("Index 0", col => col.RightAligned());
 181                table.AddColumn("Index 1", col => col.RightAligned());
 182                table.AddColumn("Index 2+", col => col.RightAligned());
 183                table.AddColumn("Total Count", col => col.RightAligned());
 184                table.AddColumn("Total Cost (USD)", col => col.RightAligned());
 185
 186                // Group data by community context, model, and category to aggregate reprediction indices
 187                var groupedData = costRows
 188                    .GroupBy(d => new { d.CommunityContext, d.Model, d.ReasoningEffort, d.Category })
 189                    .Select(g => new
 190                    {
 191                        g.Key.CommunityContext,
 192                        g.Key.Model,
 193                        ReasoningEffort = g.Key.ReasoningEffort ?? "model-default",
 194                        ModelConfigDisplayName = PredictionModelConfig.Create(g.Key.Model, g.Key.ReasoningEffort).Displa
 195                        g.Key.Category,
 196                        Index0Count = g.Where(x => x.RepredictionIndex == 0).Sum(x => x.Count),
 197                        Index0Cost = g.Where(x => x.RepredictionIndex == 0).Sum(x => x.Cost),
 198                        Index1Count = g.Where(x => x.RepredictionIndex == 1).Sum(x => x.Count),
 199                        Index1Cost = g.Where(x => x.RepredictionIndex == 1).Sum(x => x.Cost),
 200                        Index2PlusCount = g.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Count),
 201                        Index2PlusCost = g.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Cost),
 202                        TotalCount = g.Sum(x => x.Count),
 203                        TotalCost = g.Sum(x => x.Cost)
 204                    })
 205                    .OrderBy(g => g.CommunityContext)
 206                    .ThenBy(g => g.Model)
 207                    .ThenBy(g => g.ReasoningEffort)
 208                    .ThenBy(g => g.Category)
 209                    .ToList();
 210
 211                // Add rows for detailed breakdown with alternating styling
 212                for (int i = 0; i < groupedData.Count; i++)
 213                {
 214                    var data = groupedData[i];
 215                    var isEvenRow = i % 2 == 0;
 216
 217                    var index0Text = data.Index0Count > 0 ? $"{data.Index0Count} (${data.Index0Cost.ToString("F2", Cultu
 218                    var index1Text = data.Index1Count > 0 ? $"{data.Index1Count} (${data.Index1Cost.ToString("F2", Cultu
 219                    var index2PlusText = data.Index2PlusCount > 0 ? $"{data.Index2PlusCount} (${data.Index2PlusCost.ToSt
 220
 221                    if (isEvenRow)
 222                    {
 223                        // Even rows - normal styling
 224                        table.AddRow(
 225                            data.CommunityContext,
 226                            data.ModelConfigDisplayName,
 227                            data.Category,
 228                            index0Text,
 229                            index1Text,
 230                            index2PlusText,
 231                            data.TotalCount.ToString(CultureInfo.InvariantCulture),
 232                            $"${data.TotalCost.ToString("F4", CultureInfo.InvariantCulture)}"
 233                        );
 234                    }
 235                    else
 236                    {
 237                        // Odd rows - subtle blue tint for visual differentiation
 238                        table.AddRow(
 239                            $"[blue]{data.CommunityContext}[/]",
 240                            $"[blue]{data.ModelConfigDisplayName}[/]",
 241                            $"[blue]{data.Category}[/]",
 242                            $"[blue]{index0Text}[/]",
 243                            $"[blue]{index1Text}[/]",
 244                            $"[blue]{index2PlusText}[/]",
 245                            $"[blue]{data.TotalCount.ToString(CultureInfo.InvariantCulture)}[/]",
 246                            $"[blue]${data.TotalCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 247                        );
 248                    }
 249                }
 250
 251                // Add total row
 252                if (costRows.Any())
 253                {
 254                    // Calculate totals by reprediction index
 255                    var totalIndex0Count = costRows.Where(x => x.RepredictionIndex == 0).Sum(x => x.Count);
 256                    var totalIndex0Cost = costRows.Where(x => x.RepredictionIndex == 0).Sum(x => x.Cost);
 257                    var totalIndex1Count = costRows.Where(x => x.RepredictionIndex == 1).Sum(x => x.Count);
 258                    var totalIndex1Cost = costRows.Where(x => x.RepredictionIndex == 1).Sum(x => x.Cost);
 259                    var totalIndex2PlusCount = costRows.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Count);
 260                    var totalIndex2PlusCost = costRows.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Cost);
 261
 262                    var totalIndex0Text = totalIndex0Count > 0 ? $"{totalIndex0Count} (${totalIndex0Cost.ToString("F2", 
 263                    var totalIndex1Text = totalIndex1Count > 0 ? $"{totalIndex1Count} (${totalIndex1Cost.ToString("F2", 
 264                    var totalIndex2PlusText = totalIndex2PlusCount > 0 ? $"{totalIndex2PlusCount} (${totalIndex2PlusCost
 265
 266                    table.AddEmptyRow();
 267                    table.AddRow(
 268                        "[bold]Total[/]",
 269                        "",
 270                        "",
 271                        $"[bold]{totalIndex0Text}[/]",
 272                        $"[bold]{totalIndex1Text}[/]",
 273                        $"[bold]{totalIndex2PlusText}[/]",
 274                        $"[bold]{(matchPredictionCount + bonusPredictionCount).ToString(CultureInfo.InvariantCulture)}[/
 275                        $"[bold]${totalCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 276                    );
 277                }
 278            }
 279            else
 280            {
 281                // Standard summary table
 282                table.AddColumn("Category");
 283                table.AddColumn("Count", col => col.RightAligned());
 284                table.AddColumn("Cost (USD)", col => col.RightAligned());
 285
 286                var rowIndex = 0;
 287
 288                // Add Match row
 289                var isEvenRow = rowIndex % 2 == 0;
 290                if (isEvenRow)
 291                {
 292                    table.AddRow("Match", matchPredictionCount.ToString(CultureInfo.InvariantCulture), $"${matchPredicti
 293                }
 294                else
 295                {
 296                    table.AddRow(
 297                        "[blue]Match[/]",
 298                        $"[blue]{matchPredictionCount.ToString(CultureInfo.InvariantCulture)}[/]",
 299                        $"[blue]${matchPredictionCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 300                    );
 301                }
 302                rowIndex++;
 303
 304                // Add Bonus row if applicable
 305                if (settings.Bonus || settings.All)
 306                {
 307                    isEvenRow = rowIndex % 2 == 0;
 308                    if (isEvenRow)
 309                    {
 310                        table.AddRow("Bonus", bonusPredictionCount.ToString(CultureInfo.InvariantCulture), $"${bonusPred
 311                    }
 312                    else
 313                    {
 314                        table.AddRow(
 315                            "[blue]Bonus[/]",
 316                            $"[blue]{bonusPredictionCount.ToString(CultureInfo.InvariantCulture)}[/]",
 317                            $"[blue]${bonusPredictionCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 318                        );
 319                    }
 320                }
 321
 322                table.AddEmptyRow();
 323                table.AddRow("[bold]Total[/]", $"[bold]{(matchPredictionCount + bonusPredictionCount).ToString(CultureIn
 324            }
 325
 326            _console.Write(table);
 327
 328            if (!string.IsNullOrWhiteSpace(settings.OutputJson))
 329            {
 330                var report = CreateReport(
 331                    settings,
 332                    queryMatchdays,
 333                    models,
 334                    reasoningEfforts,
 335                    communityContexts,
 336                    costRows,
 337                    matchPredictionCount,
 338                    matchPredictionCost,
 339                    bonusPredictionCount,
 340                    bonusPredictionCost,
 341                    totalCost);
 342                await WriteJsonReportAsync(settings.OutputJson, report, cancellationToken);
 343                _console.MarkupLine($"[green]✓ Cost JSON written to[/] [yellow]{Path.GetFullPath(settings.OutputJson)}[/
 344            }
 345
 346            _console.MarkupLine($"[green]✓ Cost calculation completed[/]");
 347
 348            return 0;
 349        }
 350        catch (Exception ex)
 351        {
 352            _logger.LogError(ex, "Failed to calculate costs");
 353            _console.MarkupLine($"[red]✗ Failed to calculate costs: {ex.Message}[/]");
 354            return 1;
 355        }
 356    }
 357
 358    private List<int>? ParseMatchdays(CostSettings settings)
 359    {
 360        if (settings.All || string.IsNullOrWhiteSpace(settings.Matchdays))
 361            return null; // null means all matchdays
 362
 363        if (settings.Matchdays.Trim().ToLowerInvariant() == "all")
 364            return null;
 365
 366        try
 367        {
 368            return settings.Matchdays
 369                .Split(',', StringSplitOptions.RemoveEmptyEntries)
 370                .Select(md => int.Parse(md.Trim()))
 371                .ToList();
 372        }
 373        catch (FormatException)
 374        {
 375            throw new ArgumentException($"Invalid matchday format: {settings.Matchdays}. Use comma-separated numbers (e.
 376        }
 377    }
 378
 379    private static string FormatMatchdayFilter(List<int>? matchdays)
 380    {
 381        if (matchdays is null)
 382        {
 383            return "all (unfiltered; no matchday discovery)";
 384        }
 385
 386        return matchdays.Count == 0
 387            ? "none"
 388            : string.Join(", ", matchdays);
 389    }
 390
 391    private static async Task<Dictionary<int, (double cost, int count)>> GetMatchCostsByRepredictionIndexAsync(
 392        IPredictionRepository predictionRepository,
 393        PredictionModelConfig modelConfig,
 394        string communityContext,
 395        List<int>? matchdays,
 396        CancellationToken cancellationToken)
 397    {
 398        if (matchdays is { Count: 0 })
 399        {
 400            return [];
 401        }
 402
 403        if (matchdays is null || matchdays.Count <= FirestoreInFilterLimit)
 404        {
 405            return await predictionRepository.GetMatchPredictionCostsByRepredictionIndexAsync(
 406                modelConfig,
 407                communityContext,
 408                matchdays,
 409                cancellationToken);
 410        }
 411
 412        var aggregate = new Dictionary<int, (double cost, int count)>();
 413
 414        foreach (var chunk in matchdays.Chunk(FirestoreInFilterLimit))
 415        {
 416            var chunkCosts = await predictionRepository.GetMatchPredictionCostsByRepredictionIndexAsync(
 417                modelConfig,
 418                communityContext,
 419                chunk.ToList(),
 420                cancellationToken);
 421
 422            foreach (var kvp in chunkCosts)
 423            {
 424                var (existingCost, existingCount) = aggregate.GetValueOrDefault(kvp.Key);
 425                var (chunkCost, chunkCount) = kvp.Value;
 426                aggregate[kvp.Key] = (existingCost + chunkCost, existingCount + chunkCount);
 427            }
 428        }
 429
 430        return aggregate;
 431    }
 432
 433    private static CostReport CreateReport(
 434        CostSettings settings,
 435        List<int>? matchdays,
 436        List<string>? models,
 437        List<string?>? reasoningEfforts,
 438        List<string>? communityContexts,
 439        IReadOnlyList<CostReportRow> rows,
 440        int matchPredictionCount,
 441        double matchPredictionCost,
 442        int bonusPredictionCount,
 443        double bonusPredictionCost,
 444        double totalCost)
 445    {
 446        var categoryTotals = rows
 447            .GroupBy(row => new { row.Category, row.RepredictionIndex })
 448            .Select(group => new CostReportCategoryTotal(
 449                group.Key.Category,
 450                group.Key.RepredictionIndex,
 451                group.Sum(row => row.Count),
 452                group.Sum(row => row.Cost)))
 453            .OrderBy(total => total.Category, StringComparer.Ordinal)
 454            .ThenBy(total => total.RepredictionIndex)
 455            .ToList();
 456
 457        return new CostReport(
 458            new CostReportFilters(
 459                matchdays,
 460                models,
 461                reasoningEfforts,
 462                communityContexts,
 463                settings.Bonus || settings.All,
 464                settings.All),
 465            rows
 466                .OrderBy(row => row.CommunityContext, StringComparer.Ordinal)
 467                .ThenBy(row => row.Model, StringComparer.Ordinal)
 468                .ThenBy(row => row.ReasoningEffort ?? string.Empty, StringComparer.Ordinal)
 469                .ThenBy(row => row.Category, StringComparer.Ordinal)
 470                .ThenBy(row => row.RepredictionIndex)
 471                .ToList(),
 472            categoryTotals,
 473            new CostReportTotal(
 474                matchPredictionCount,
 475                matchPredictionCost,
 476                bonusPredictionCount,
 477                bonusPredictionCost,
 478                matchPredictionCount + bonusPredictionCount,
 479                totalCost));
 480    }
 481
 482    private static async Task WriteJsonReportAsync(
 483        string outputPath,
 484        CostReport report,
 485        CancellationToken cancellationToken)
 486    {
 487        var resolvedPath = Path.GetFullPath(outputPath);
 488        var directory = Path.GetDirectoryName(resolvedPath);
 489
 490        if (!string.IsNullOrWhiteSpace(directory))
 491        {
 492            Directory.CreateDirectory(directory);
 493        }
 494
 495        await File.WriteAllTextAsync(
 496            resolvedPath,
 497            JsonSerializer.Serialize(report, OutputJsonOptions),
 498            cancellationToken);
 499    }
 500
 501    private List<string>? ParseModels(CostSettings settings)
 502    {
 503        if (settings.All || string.IsNullOrWhiteSpace(settings.Models))
 504            return null; // null means all models
 505
 506        if (settings.Models.Trim().ToLowerInvariant() == "all")
 507            return null;
 508
 509        return settings.Models
 510            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 511            .Select(m => m.Trim())
 512            .Where(m => !string.IsNullOrWhiteSpace(m))
 513            .ToList();
 514    }
 515
 516    private static List<string?>? ParseReasoningEfforts(CostSettings settings)
 517    {
 518        if (settings.All || string.IsNullOrWhiteSpace(settings.ReasoningEfforts))
 519        {
 520            return null;
 521        }
 522
 523        if (settings.ReasoningEfforts.Trim().Equals("all", StringComparison.OrdinalIgnoreCase))
 524        {
 525            return null;
 526        }
 527
 528        var efforts = new List<string?>();
 529        foreach (var segment in settings.ReasoningEfforts.Split(',', StringSplitOptions.RemoveEmptyEntries))
 530        {
 531            var trimmed = segment.Trim();
 532            if (trimmed.Equals("model-default", StringComparison.OrdinalIgnoreCase))
 533            {
 534                efforts.Add(null);
 535                continue;
 536            }
 537
 538            if (!PredictionModelConfig.IsValidReasoningEffort(trimmed))
 539            {
 540                throw new ArgumentException("--reasoning-efforts must contain only: model-default, none, minimal, low, m
 541            }
 542
 543            efforts.Add(PredictionModelConfig.NormalizeReasoningEffort(trimmed));
 544        }
 545
 546        return efforts.Distinct().ToList();
 547    }
 548
 549    private static async Task<List<PredictionModelConfig>> ResolveModelConfigsAsync(
 550        IPredictionRepository predictionRepository,
 551        List<string>? models,
 552        List<string?>? reasoningEfforts,
 553        CancellationToken cancellationToken)
 554    {
 555        var availableModelConfigs = await predictionRepository.GetAvailableModelConfigsAsync(cancellationToken);
 556
 557        var filtered = availableModelConfigs.AsEnumerable();
 558        if (models is not null)
 559        {
 560            var requestedModels = models.ToHashSet(StringComparer.Ordinal);
 561            filtered = filtered.Where(config => requestedModels.Contains(config.Model));
 562        }
 563
 564        if (reasoningEfforts is not null)
 565        {
 566            var requestedEfforts = reasoningEfforts.ToHashSet(StringComparer.Ordinal);
 567            filtered = filtered.Where(config => requestedEfforts.Contains(config.ReasoningEffort));
 568        }
 569
 570        return filtered
 571            .OrderBy(config => config.Model, StringComparer.Ordinal)
 572            .ThenBy(config => config.ReasoningEffort ?? string.Empty, StringComparer.Ordinal)
 573            .ToList();
 574    }
 575
 576    private List<string>? ParseCommunityContexts(CostSettings settings)
 577    {
 578        if (settings.All || string.IsNullOrWhiteSpace(settings.CommunityContexts))
 579            return null; // null means all community contexts
 580
 581        if (settings.CommunityContexts.Trim().ToLowerInvariant() == "all")
 582            return null;
 583
 584        return settings.CommunityContexts
 585            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 586            .Select(cc => cc.Trim())
 587            .Where(cc => !string.IsNullOrWhiteSpace(cc))
 588            .ToList();
 589    }
 590
 591    private async Task<CostConfiguration> LoadConfigurationFromFile(string configFilePath)
 592    {
 593        try
 594        {
 595            // Resolve relative paths
 596            var resolvedPath = Path.IsPathRooted(configFilePath)
 597                ? configFilePath
 598                : Path.Combine(Directory.GetCurrentDirectory(), configFilePath);
 599
 600            if (!File.Exists(resolvedPath))
 601            {
 602                throw new FileNotFoundException($"Configuration file not found: {resolvedPath}");
 603            }
 604
 605            _logger.LogInformation("Loading configuration from: {ConfigPath}", resolvedPath);
 606
 607            var jsonContent = await File.ReadAllTextAsync(resolvedPath);
 608            var options = new JsonSerializerOptions
 609            {
 610                PropertyNameCaseInsensitive = true,
 611                AllowTrailingCommas = true,
 612                ReadCommentHandling = JsonCommentHandling.Skip
 613            };
 614
 615            var config = JsonSerializer.Deserialize<CostConfiguration>(jsonContent, options);
 616            if (config == null)
 617            {
 618                throw new InvalidOperationException($"Failed to deserialize configuration from: {resolvedPath}");
 619            }
 620
 621            return config;
 622        }
 623        catch (JsonException ex)
 624        {
 625            throw new InvalidOperationException($"Invalid JSON in configuration file: {configFilePath}. {ex.Message}", e
 626        }
 627        catch (Exception ex)
 628        {
 629            throw new InvalidOperationException($"Failed to load configuration from file: {configFilePath}. {ex.Message}
 630        }
 631    }
 632
 633    private CostSettings MergeConfigurations(CostConfiguration fileConfig, CostSettings cliSettings)
 634    {
 635        _logger.LogInformation("Merging file configuration with command line options (CLI options take precedence)");
 636
 637        // Create a new settings object with file config as base, CLI overrides
 638        var mergedSettings = new CostSettings();
 639
 640        // Apply file config first (if values are not null/default)
 641        if (!string.IsNullOrWhiteSpace(fileConfig.Matchdays))
 642            mergedSettings.Matchdays = fileConfig.Matchdays;
 643
 644        if (fileConfig.Bonus.HasValue)
 645            mergedSettings.Bonus = fileConfig.Bonus.Value;
 646
 647        if (!string.IsNullOrWhiteSpace(fileConfig.Models))
 648            mergedSettings.Models = fileConfig.Models;
 649
 650        if (!string.IsNullOrWhiteSpace(fileConfig.ReasoningEfforts))
 651            mergedSettings.ReasoningEfforts = fileConfig.ReasoningEfforts;
 652
 653        if (!string.IsNullOrWhiteSpace(fileConfig.CommunityContexts))
 654            mergedSettings.CommunityContexts = fileConfig.CommunityContexts;
 655
 656        if (fileConfig.All.HasValue)
 657            mergedSettings.All = fileConfig.All.Value;
 658
 659        if (fileConfig.Verbose.HasValue)
 660            mergedSettings.Verbose = fileConfig.Verbose.Value;
 661
 662        if (fileConfig.DetailedBreakdown.HasValue)
 663            mergedSettings.DetailedBreakdown = fileConfig.DetailedBreakdown.Value;
 664
 665        if (!string.IsNullOrWhiteSpace(fileConfig.OutputJson))
 666            mergedSettings.OutputJson = fileConfig.OutputJson;
 667
 668        // Override with CLI settings (non-default values)
 669        if (!string.IsNullOrWhiteSpace(cliSettings.Matchdays))
 670        {
 671            mergedSettings.Matchdays = cliSettings.Matchdays;
 672            if (mergedSettings.Verbose)
 673                _logger.LogInformation("CLI override: Matchdays = {Value}", cliSettings.Matchdays);
 674        }
 675
 676        if (cliSettings.Bonus) // Only override if explicitly set to true
 677        {
 678            mergedSettings.Bonus = cliSettings.Bonus;
 679            if (mergedSettings.Verbose)
 680                _logger.LogInformation("CLI override: Bonus = {Value}", cliSettings.Bonus);
 681        }
 682
 683        if (!string.IsNullOrWhiteSpace(cliSettings.Models))
 684        {
 685            mergedSettings.Models = cliSettings.Models;
 686            if (mergedSettings.Verbose)
 687                _logger.LogInformation("CLI override: Models = {Value}", cliSettings.Models);
 688        }
 689
 690        if (!string.IsNullOrWhiteSpace(cliSettings.ReasoningEfforts))
 691        {
 692            mergedSettings.ReasoningEfforts = cliSettings.ReasoningEfforts;
 693            if (mergedSettings.Verbose)
 694                _logger.LogInformation("CLI override: ReasoningEfforts = {Value}", cliSettings.ReasoningEfforts);
 695        }
 696
 697        if (!string.IsNullOrWhiteSpace(cliSettings.CommunityContexts))
 698        {
 699            mergedSettings.CommunityContexts = cliSettings.CommunityContexts;
 700            if (mergedSettings.Verbose)
 701                _logger.LogInformation("CLI override: CommunityContexts = {Value}", cliSettings.CommunityContexts);
 702        }
 703
 704        if (cliSettings.All) // Only override if explicitly set to true
 705        {
 706            mergedSettings.All = cliSettings.All;
 707            if (mergedSettings.Verbose)
 708                _logger.LogInformation("CLI override: All = {Value}", cliSettings.All);
 709        }
 710
 711        if (cliSettings.Verbose) // Only override if explicitly set to true
 712        {
 713            mergedSettings.Verbose = cliSettings.Verbose;
 714        }
 715
 716        if (cliSettings.DetailedBreakdown) // Only override if explicitly set to true
 717        {
 718            mergedSettings.DetailedBreakdown = cliSettings.DetailedBreakdown;
 719            if (mergedSettings.Verbose)
 720                _logger.LogInformation("CLI override: DetailedBreakdown = {Value}", cliSettings.DetailedBreakdown);
 721        }
 722
 723        if (!string.IsNullOrWhiteSpace(cliSettings.OutputJson))
 724        {
 725            mergedSettings.OutputJson = cliSettings.OutputJson;
 726            if (mergedSettings.Verbose)
 727                _logger.LogInformation("CLI override: OutputJson = {Value}", cliSettings.OutputJson);
 728        }
 729
 730        // Always preserve the ConfigFile setting
 731        mergedSettings.ConfigFile = cliSettings.ConfigFile;
 732
 733        return mergedSettings;
 734    }
 735
 1736    private sealed record CostReport(
 1737        CostReportFilters Filters,
 1738        IReadOnlyList<CostReportRow> Rows,
 1739        IReadOnlyList<CostReportCategoryTotal> CategoryTotals,
 1740        CostReportTotal Total);
 741
 742    private sealed record CostReportFilters(
 743        IReadOnlyList<int>? Matchdays,
 744        IReadOnlyList<string>? Models,
 745        IReadOnlyList<string?>? ReasoningEfforts,
 746        IReadOnlyList<string>? CommunityContexts,
 747        bool IncludeBonus,
 748        bool All);
 749
 750    private sealed record CostReportRow(
 751        string CommunityContext,
 752        string Model,
 753        string? ReasoningEffort,
 754        string ModelConfigKey,
 755        string ModelConfigDisplayName,
 756        string Category,
 757        int RepredictionIndex,
 758        int Count,
 759        double Cost);
 760
 761    private sealed record CostReportCategoryTotal(
 762        string Category,
 763        int RepredictionIndex,
 764        int Count,
 765        double Cost);
 766
 767    private sealed record CostReportTotal(
 768        int MatchPredictionCount,
 769        double MatchPredictionCost,
 770        int BonusPredictionCount,
 771        double BonusPredictionCost,
 772        int Count,
 773        double Cost);
 774}