< Summary

Information
Class: FirebaseAdapter.FirebasePredictionRepository.StoredContextSource
Assembly: FirebaseAdapter
File(s): /home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebasePredictionRepository.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 1373
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
get_DocumentName()100%11100%
set_DocumentName(...)100%11100%
get_Details()100%11100%
set_Details(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebasePredictionRepository.cs

#LineLine coverage
 1using System.Collections.Generic;
 2using System.Linq;
 3using System.Text.Json;
 4using System.Text.Json.Serialization;
 5using EHonda.KicktippAi.Core;
 6using FirebaseAdapter.Models;
 7using Google.Cloud.Firestore;
 8using Microsoft.Extensions.Logging;
 9using NodaTime;
 10
 11namespace FirebaseAdapter;
 12
 13/// <summary>
 14/// Firebase Firestore implementation of the prediction repository.
 15/// </summary>
 16public class FirebasePredictionRepository : IPredictionRepository
 17{
 18    private static readonly JsonSerializerOptions JustificationSerializerOptions = new()
 19    {
 20        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 21        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 22    };
 23
 24    private readonly FirestoreDb _firestoreDb;
 25    private readonly ILogger<FirebasePredictionRepository> _logger;
 26    private readonly string _predictionsCollection;
 27    private readonly string _matchesCollection;
 28    private readonly string _bonusPredictionsCollection;
 29    private readonly string _competition;
 30
 31    public FirebasePredictionRepository(FirestoreDb firestoreDb, ILogger<FirebasePredictionRepository> logger)
 32    {
 33        _firestoreDb = firestoreDb ?? throw new ArgumentNullException(nameof(firestoreDb));
 34        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 35
 36        // Use unified collection names (no longer community-specific)
 37        _predictionsCollection = "match-predictions";
 38        _matchesCollection = "matches";
 39        _bonusPredictionsCollection = "bonus-predictions";
 40        _competition = "bundesliga-2025-26"; // Remove community suffix
 41
 42        _logger.LogInformation("Firebase repository initialized");
 43    }
 44
 45    public async Task SavePredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double co
 46    {
 47        try
 48        {
 49            var now = Timestamp.GetCurrentTimestamp();
 50
 51            // Check if a prediction already exists for this match, model, and community context
 52            // Order by repredictionIndex descending to get the latest version for updating
 53            var query = _firestoreDb.Collection(_predictionsCollection)
 54                .WhereEqualTo("homeTeam", match.HomeTeam)
 55                .WhereEqualTo("awayTeam", match.AwayTeam)
 56                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 57                .WhereEqualTo("competition", _competition)
 58                .WhereEqualTo("model", model)
 59                .WhereEqualTo("communityContext", communityContext)
 60                .OrderByDescending("repredictionIndex")
 61                .Limit(1);
 62
 63            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 64
 65            DocumentReference docRef;
 66            bool isUpdate = false;
 67            Timestamp? existingCreatedAt = null;
 68            int repredictionIndex = 0;
 69
 70            if (snapshot.Documents.Count > 0)
 71            {
 72                // Update existing document (latest reprediction)
 73                var existingDoc = snapshot.Documents.First();
 74                docRef = existingDoc.Reference;
 75                isUpdate = true;
 76
 77                // Preserve the original values
 78                var existingData = existingDoc.ConvertTo<FirestoreMatchPrediction>();
 79                existingCreatedAt = existingData.CreatedAt;
 80                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 81
 82                _logger.LogDebug("Updating existing prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId
 83                    match.HomeTeam, match.AwayTeam, existingDoc.Id, repredictionIndex);
 84            }
 85            else
 86            {
 87                // Create new document
 88                var documentId = Guid.NewGuid().ToString();
 89                docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 90                repredictionIndex = 0; // First prediction
 91
 92                _logger.LogDebug("Creating new prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, re
 93                    match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 94            }
 95
 96            var firestorePrediction = new FirestoreMatchPrediction
 97            {
 98                Id = docRef.Id,
 99                HomeTeam = match.HomeTeam,
 100                AwayTeam = match.AwayTeam,
 101                StartsAt = ConvertToTimestamp(match.StartsAt),
 102                Matchday = match.Matchday,
 103                HomeGoals = prediction.HomeGoals,
 104                AwayGoals = prediction.AwayGoals,
 105                Justification = SerializeJustification(prediction.Justification),
 106                UpdatedAt = now,
 107                Competition = _competition,
 108                Model = model,
 109                TokenUsage = tokenUsage,
 110                Cost = cost,
 111                CommunityContext = communityContext,
 112                ContextDocumentNames = contextDocumentNames.ToArray(),
 113                RepredictionIndex = repredictionIndex
 114            };
 115
 116            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 117            firestorePrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreatedAt.V
 118
 119            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 120
 121            var action = isUpdate ? "Updated" : "Saved";
 122            _logger.LogInformation("{Action} prediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repre
 123                action, match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 124        }
 125        catch (Exception ex)
 126        {
 127            _logger.LogError(ex, "Failed to save prediction for match {HomeTeam} vs {AwayTeam}",
 128                match.HomeTeam, match.AwayTeam);
 129            throw;
 130        }
 131    }
 132
 133    public async Task<Prediction?> GetPredictionAsync(Match match, string model, string communityContext, CancellationTo
 134    {
 135        return await GetPredictionAsync(match.HomeTeam, match.AwayTeam, match.StartsAt, model, communityContext, cancell
 136    }
 137
 138    public async Task<Prediction?> GetPredictionAsync(string homeTeam, string awayTeam, ZonedDateTime startsAt, string m
 139    {
 140        try
 141        {
 142            // Query by match characteristics, model, community context, and competition
 143            // Order by repredictionIndex descending to get the latest version
 144            var query = _firestoreDb.Collection(_predictionsCollection)
 145                .WhereEqualTo("homeTeam", homeTeam)
 146                .WhereEqualTo("awayTeam", awayTeam)
 147                .WhereEqualTo("startsAt", ConvertToTimestamp(startsAt))
 148                .WhereEqualTo("competition", _competition)
 149                .WhereEqualTo("model", model)
 150                .WhereEqualTo("communityContext", communityContext)
 151                .OrderByDescending("repredictionIndex")
 152                .Limit(1);
 153
 154            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 155
 156            if (snapshot.Documents.Count == 0)
 157            {
 158                return null;
 159            }
 160
 161            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 162            return new Prediction(
 163                firestorePrediction.HomeGoals,
 164                firestorePrediction.AwayGoals,
 165                DeserializeJustification(firestorePrediction.Justification));
 166        }
 167        catch (Exception ex)
 168        {
 169            _logger.LogError(ex, "Failed to get prediction for match {HomeTeam} vs {AwayTeam} using model {Model} and co
 170                homeTeam, awayTeam, model, communityContext);
 171            throw;
 172        }
 173    }
 174
 175    public async Task<PredictionMetadata?> GetPredictionMetadataAsync(Match match, string model, string communityContext
 176    {
 177        try
 178        {
 179            // Query by match characteristics, model, community context, and competition.
 180            // Order by repredictionIndex descending to keep metadata reads aligned with latest prediction retrieval.
 181            var query = _firestoreDb.Collection(_predictionsCollection)
 182                .WhereEqualTo("homeTeam", match.HomeTeam)
 183                .WhereEqualTo("awayTeam", match.AwayTeam)
 184                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 185                .WhereEqualTo("competition", _competition)
 186                .WhereEqualTo("model", model)
 187                .WhereEqualTo("communityContext", communityContext)
 188                .OrderByDescending("repredictionIndex")
 189                .Limit(1);
 190
 191            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 192
 193            if (snapshot.Documents.Count == 0)
 194            {
 195                return null;
 196            }
 197
 198            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 199            var prediction = new Prediction(
 200                firestorePrediction.HomeGoals,
 201                firestorePrediction.AwayGoals,
 202                DeserializeJustification(firestorePrediction.Justification));
 203            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 204            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 205
 206            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 207        }
 208        catch (Exception ex)
 209        {
 210            _logger.LogError(ex, "Failed to get prediction metadata for match {HomeTeam} vs {AwayTeam} using model {Mode
 211                match.HomeTeam, match.AwayTeam, model, communityContext);
 212            throw;
 213        }
 214    }
 215
 216    public async Task<IReadOnlyList<Match>> GetMatchDayAsync(int matchDay, CancellationToken cancellationToken = default
 217    {
 218        try
 219        {
 220            var query = _firestoreDb.Collection(_matchesCollection)
 221                .WhereEqualTo("competition", _competition)
 222                .WhereEqualTo("matchday", matchDay)
 223                .OrderBy("startsAt");
 224
 225            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 226
 227            var matches = snapshot.Documents
 228                .Select(doc => doc.ConvertTo<FirestoreMatch>())
 229                .Select(fm => new Match(
 230                    fm.HomeTeam,
 231                    fm.AwayTeam,
 232                    ConvertFromTimestamp(fm.StartsAt),
 233                    fm.Matchday,
 234                    fm.IsCancelled))
 235                .ToList();
 236
 237            return matches.AsReadOnly();
 238        }
 239        catch (Exception ex)
 240        {
 241            _logger.LogError(ex, "Failed to get matches for matchday {Matchday}", matchDay);
 242            throw;
 243        }
 244    }
 245
 246    public async Task<Match?> GetStoredMatchAsync(string homeTeam, string awayTeam, int matchDay, string? model = null, 
 247    {
 248        try
 249        {
 250            var matchQuery = _firestoreDb.Collection(_matchesCollection)
 251                .WhereEqualTo("competition", _competition)
 252                .WhereEqualTo("matchday", matchDay)
 253                .WhereEqualTo("homeTeam", homeTeam)
 254                .WhereEqualTo("awayTeam", awayTeam);
 255
 256            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 257
 258            if (matchSnapshot.Documents.Count > 0)
 259            {
 260                if (matchSnapshot.Documents.Count > 1)
 261                {
 262                    _logger.LogWarning("Found {Count} stored match documents for {HomeTeam} vs {AwayTeam} on matchday {M
 263                }
 264
 265                return matchSnapshot.Documents
 266                    .Select(document => document.ConvertTo<FirestoreMatch>())
 267                    .Select(firestoreMatch => new Match(
 268                        firestoreMatch.HomeTeam,
 269                        firestoreMatch.AwayTeam,
 270                        ConvertFromTimestamp(firestoreMatch.StartsAt),
 271                        firestoreMatch.Matchday,
 272                        firestoreMatch.IsCancelled))
 273                    .OrderBy(match => match.StartsAt.ToInstant())
 274                    .ThenBy(match => match.IsCancelled)
 275                    .First();
 276            }
 277
 278            Query predictionQuery = _firestoreDb.Collection(_predictionsCollection)
 279                .WhereEqualTo("competition", _competition)
 280                .WhereEqualTo("matchday", matchDay)
 281                .WhereEqualTo("homeTeam", homeTeam)
 282                .WhereEqualTo("awayTeam", awayTeam);
 283
 284            if (!string.IsNullOrWhiteSpace(model))
 285            {
 286                predictionQuery = predictionQuery.WhereEqualTo("model", model);
 287            }
 288
 289            if (!string.IsNullOrWhiteSpace(communityContext))
 290            {
 291                predictionQuery = predictionQuery.WhereEqualTo("communityContext", communityContext);
 292            }
 293
 294            var predictionSnapshot = await predictionQuery.GetSnapshotAsync(cancellationToken);
 295
 296            if (predictionSnapshot.Documents.Count == 0)
 297            {
 298                return null;
 299            }
 300
 301            if (predictionSnapshot.Documents.Count > 1)
 302            {
 303                _logger.LogWarning("Found {Count} stored prediction documents for {HomeTeam} vs {AwayTeam} on matchday {
 304            }
 305
 306            var firestorePrediction = predictionSnapshot.Documents
 307                .Select(document => document.ConvertTo<FirestoreMatchPrediction>())
 308                .OrderByDescending(prediction => prediction.RepredictionIndex)
 309                .ThenByDescending(prediction => prediction.CreatedAt.ToDateTimeOffset())
 310                .ThenBy(prediction => prediction.StartsAt.ToDateTimeOffset())
 311                .ThenBy(prediction => prediction.Id, StringComparer.Ordinal)
 312                .First();
 313
 314            return new Match(
 315                firestorePrediction.HomeTeam,
 316                firestorePrediction.AwayTeam,
 317                ConvertFromTimestamp(firestorePrediction.StartsAt),
 318                firestorePrediction.Matchday);
 319        }
 320        catch (Exception ex)
 321        {
 322            _logger.LogError(ex, "Failed to get stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}", homeTeam
 323            throw;
 324        }
 325    }
 326
 327    public async Task<IReadOnlyList<MatchPrediction>> GetMatchDayWithPredictionsAsync(int matchDay, string model, string
 328    {
 329        try
 330        {
 331            // Get all matches for the matchday
 332            var matches = await GetMatchDayAsync(matchDay, cancellationToken);
 333
 334            // Get predictions for all matches using the specified model and community context
 335            var matchPredictions = new List<MatchPrediction>();
 336
 337            foreach (var match in matches)
 338            {
 339                var prediction = await GetPredictionAsync(match, model, communityContext, cancellationToken);
 340                matchPredictions.Add(new MatchPrediction(match, prediction));
 341            }
 342
 343            return matchPredictions.AsReadOnly();
 344        }
 345        catch (Exception ex)
 346        {
 347            _logger.LogError(ex, "Failed to get matches with predictions for matchday {Matchday} using model {Model} and
 348            throw;
 349        }
 350    }
 351
 352    public async Task<IReadOnlyList<MatchPrediction>> GetAllPredictionsAsync(string model, string communityContext, Canc
 353    {
 354        try
 355        {
 356            var query = _firestoreDb.Collection(_predictionsCollection)
 357                .WhereEqualTo("competition", _competition)
 358                .WhereEqualTo("model", model)
 359                .WhereEqualTo("communityContext", communityContext)
 360                .OrderBy("matchday");
 361
 362            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 363
 364            var matchPredictions = snapshot.Documents
 365                .Select(doc => doc.ConvertTo<FirestoreMatchPrediction>())
 366                .Select(fp => new MatchPrediction(
 367                    new Match(fp.HomeTeam, fp.AwayTeam, ConvertFromTimestamp(fp.StartsAt), fp.Matchday),
 368                    new Prediction(
 369                        fp.HomeGoals,
 370                        fp.AwayGoals,
 371                        DeserializeJustification(fp.Justification))))
 372                .ToList();
 373
 374            return matchPredictions.AsReadOnly();
 375        }
 376        catch (Exception ex)
 377        {
 378            _logger.LogError(ex, "Failed to get all predictions for model {Model} and community context {CommunityContex
 379            throw;
 380        }
 381    }
 382
 383    public async Task<bool> HasPredictionAsync(Match match, string model, string communityContext, CancellationToken can
 384    {
 385        try
 386        {
 387            // Query by match characteristics, model, and community context instead of using deterministic ID
 388            var query = _firestoreDb.Collection(_predictionsCollection)
 389                .WhereEqualTo("homeTeam", match.HomeTeam)
 390                .WhereEqualTo("awayTeam", match.AwayTeam)
 391                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 392                .WhereEqualTo("competition", _competition)
 393                .WhereEqualTo("model", model)
 394                .WhereEqualTo("communityContext", communityContext);
 395
 396            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 397            return snapshot.Documents.Count > 0;
 398        }
 399        catch (Exception ex)
 400        {
 401            _logger.LogError(ex, "Failed to check if prediction exists for match {HomeTeam} vs {AwayTeam} using model {M
 402                match.HomeTeam, match.AwayTeam, model, communityContext);
 403            throw;
 404        }
 405    }
 406
 407    public async Task SaveBonusPredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mode
 408    {
 409        try
 410        {
 411            var now = Timestamp.GetCurrentTimestamp();
 412
 413            // Check if a prediction already exists for this question, model, and community context
 414            // Order by repredictionIndex descending to get the latest version for updating
 415            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 416                .WhereEqualTo("questionText", bonusQuestion.Text)
 417                .WhereEqualTo("competition", _competition)
 418                .WhereEqualTo("model", model)
 419                .WhereEqualTo("communityContext", communityContext)
 420                .OrderByDescending("repredictionIndex")
 421                .Limit(1);
 422
 423            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 424
 425            DocumentReference docRef;
 426            bool isUpdate = false;
 427            Timestamp? existingCreatedAt = null;
 428            int repredictionIndex = 0;
 429
 430            if (snapshot.Documents.Count > 0)
 431            {
 432                // Update existing document (latest reprediction)
 433                var existingDoc = snapshot.Documents.First();
 434                docRef = existingDoc.Reference;
 435                isUpdate = true;
 436
 437                // Preserve the original values
 438                var existingData = existingDoc.ConvertTo<FirestoreBonusPrediction>();
 439                existingCreatedAt = existingData.CreatedAt;
 440                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 441
 442                _logger.LogDebug("Updating existing bonus prediction for question '{QuestionText}' (document: {DocumentI
 443                    bonusQuestion.Text, existingDoc.Id, repredictionIndex);
 444            }
 445            else
 446            {
 447                // Create new document
 448                var documentId = Guid.NewGuid().ToString();
 449                docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 450                repredictionIndex = 0; // First prediction
 451
 452                _logger.LogDebug("Creating new bonus prediction for question '{QuestionText}' (document: {DocumentId}, r
 453                    bonusQuestion.Text, documentId, repredictionIndex);
 454            }
 455
 456            // Extract selected option texts for observability
 457            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 458            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 459                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 460                .ToArray();
 461
 462            var firestoreBonusPrediction = new FirestoreBonusPrediction
 463            {
 464                Id = docRef.Id,
 465                QuestionText = bonusQuestion.Text,
 466                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 467                SelectedOptionTexts = selectedOptionTexts,
 468                UpdatedAt = now,
 469                Competition = _competition,
 470                Model = model,
 471                TokenUsage = tokenUsage,
 472                Cost = cost,
 473                CommunityContext = communityContext,
 474                ContextDocumentNames = contextDocumentNames.ToArray(),
 475                RepredictionIndex = repredictionIndex
 476            };
 477
 478            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 479            firestoreBonusPrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreate
 480
 481            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 482
 483            var action = isUpdate ? "Updated" : "Saved";
 484            _logger.LogDebug("{Action} bonus prediction for question '{QuestionText}' with selections: {SelectedOptions}
 485                action, bonusQuestion.Text, string.Join(", ", selectedOptionTexts), repredictionIndex);
 486        }
 487        catch (Exception ex)
 488        {
 489            _logger.LogError(ex, "Failed to save bonus prediction for question: {QuestionText}",
 490                bonusQuestion.Text);
 491            throw;
 492        }
 493    }
 494
 495    public async Task<BonusPrediction?> GetBonusPredictionAsync(string questionId, string model, string communityContext
 496    {
 497        try
 498        {
 499            // Query by questionId, model, community context, and competition instead of using direct document lookup
 500            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 501                .WhereEqualTo("questionId", questionId)
 502                .WhereEqualTo("competition", _competition)
 503                .WhereEqualTo("model", model)
 504                .WhereEqualTo("communityContext", communityContext);
 505
 506            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 507
 508            if (snapshot.Documents.Count == 0)
 509            {
 510                return null;
 511            }
 512
 513            var firestoreBonusPrediction = snapshot.Documents
 514                .Select(document => document.ConvertTo<FirestoreBonusPrediction>())
 515                .OrderByDescending(prediction => prediction.RepredictionIndex)
 516                .ThenByDescending(prediction => prediction.CreatedAt.ToDateTimeOffset())
 517                .ThenBy(prediction => prediction.Id, StringComparer.Ordinal)
 518                .First();
 519
 520            return new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 521        }
 522        catch (Exception ex)
 523        {
 524            _logger.LogError(ex, "Failed to get bonus prediction for question {QuestionId} using model {Model} and commu
 525            throw;
 526        }
 527    }
 528
 529    public async Task<BonusPrediction?> GetBonusPredictionByTextAsync(string questionText, string model, string communit
 530    {
 531        try
 532        {
 533            // Query by questionText, model, and community context
 534            // Order by repredictionIndex descending to get the latest version
 535            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 536                .WhereEqualTo("questionText", questionText)
 537                .WhereEqualTo("competition", _competition)
 538                .WhereEqualTo("model", model)
 539                .WhereEqualTo("communityContext", communityContext)
 540                .OrderByDescending("repredictionIndex")
 541                .Limit(1);
 542
 543            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 544
 545            if (snapshot.Documents.Count == 0)
 546            {
 547                _logger.LogDebug("No bonus prediction found for question text: {QuestionText} with model: {Model} and co
 548                return null;
 549            }
 550
 551            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 552            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 553
 554            _logger.LogDebug("Found bonus prediction for question text: {QuestionText} with model: {Model} and community
 555                questionText, model, communityContext, firestoreBonusPrediction.RepredictionIndex);
 556
 557            return bonusPrediction;
 558        }
 559        catch (Exception ex)
 560        {
 561            _logger.LogError(ex, "Failed to retrieve bonus prediction by text: {QuestionText} with model: {Model} and co
 562            throw;
 563        }
 564    }
 565
 566    public async Task<BonusPredictionMetadata?> GetBonusPredictionMetadataByTextAsync(string questionText, string model,
 567    {
 568        try
 569        {
 570            // Query by questionText, model, and community context.
 571            // Order by repredictionIndex descending to align metadata reads with latest bonus prediction retrieval.
 572            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 573                .WhereEqualTo("questionText", questionText)
 574                .WhereEqualTo("competition", _competition)
 575                .WhereEqualTo("model", model)
 576                .WhereEqualTo("communityContext", communityContext)
 577                .OrderByDescending("repredictionIndex")
 578                .Limit(1);
 579
 580            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 581
 582            if (snapshot.Documents.Count == 0)
 583            {
 584                _logger.LogDebug("No bonus prediction metadata found for question text: {QuestionText} with model: {Mode
 585                return null;
 586            }
 587
 588            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 589            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 590            var createdAt = firestoreBonusPrediction.CreatedAt.ToDateTimeOffset();
 591            var contextDocumentNames = firestoreBonusPrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 592
 593            _logger.LogDebug("Found bonus prediction metadata for question text: {QuestionText} with model: {Model} and 
 594                questionText, model, communityContext);
 595
 596            return new BonusPredictionMetadata(bonusPrediction, createdAt, contextDocumentNames);
 597        }
 598        catch (Exception ex)
 599        {
 600            _logger.LogError(ex, "Failed to retrieve bonus prediction metadata by text: {QuestionText} with model: {Mode
 601            throw;
 602        }
 603    }
 604
 605    public async Task<IReadOnlyList<BonusPrediction>> GetAllBonusPredictionsAsync(string model, string communityContext,
 606    {
 607        try
 608        {
 609            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 610                .WhereEqualTo("competition", _competition)
 611                .WhereEqualTo("model", model)
 612                .WhereEqualTo("communityContext", communityContext)
 613                .OrderBy("createdAt");
 614
 615            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 616
 617            var bonusPredictions = new List<BonusPrediction>();
 618            foreach (var document in snapshot.Documents)
 619            {
 620                var firestoreBonusPrediction = document.ConvertTo<FirestoreBonusPrediction>();
 621                bonusPredictions.Add(new BonusPrediction(
 622                    firestoreBonusPrediction.SelectedOptionIds.ToList()));
 623            }
 624
 625            return bonusPredictions.AsReadOnly();
 626        }
 627        catch (Exception ex)
 628        {
 629            _logger.LogError(ex, "Failed to get all bonus predictions for model {Model} and community context {Community
 630            throw;
 631        }
 632    }
 633
 634    public async Task<bool> HasBonusPredictionAsync(string questionId, string model, string communityContext, Cancellati
 635    {
 636        try
 637        {
 638            // Query by questionId, model, and community context instead of using direct document lookup
 639            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 640                .WhereEqualTo("questionId", questionId)
 641                .WhereEqualTo("competition", _competition)
 642                .WhereEqualTo("model", model)
 643                .WhereEqualTo("communityContext", communityContext);
 644
 645            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 646            return snapshot.Documents.Count > 0;
 647        }
 648        catch (Exception ex)
 649        {
 650            _logger.LogError(ex, "Failed to check if bonus prediction exists for question {QuestionId} using model {Mode
 651            throw;
 652        }
 653    }
 654
 655    /// <summary>
 656    /// Stores a match in the matches collection for matchday management.
 657    /// This is typically called when importing match schedules.
 658    /// </summary>
 659    public async Task StoreMatchAsync(Match match, CancellationToken cancellationToken = default)
 660    {
 661        try
 662        {
 663            var documentId = Guid.NewGuid().ToString();
 664
 665            var firestoreMatch = new FirestoreMatch
 666            {
 667                Id = documentId,
 668                HomeTeam = match.HomeTeam,
 669                AwayTeam = match.AwayTeam,
 670                StartsAt = ConvertToTimestamp(match.StartsAt),
 671                Matchday = match.Matchday,
 672                Competition = _competition,
 673                IsCancelled = match.IsCancelled
 674            };
 675
 676            await _firestoreDb.Collection(_matchesCollection)
 677                .Document(documentId)
 678                .SetAsync(firestoreMatch, cancellationToken: cancellationToken);
 679
 680            _logger.LogDebug("Stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}{Cancelled}",
 681                match.HomeTeam, match.AwayTeam, match.Matchday, match.IsCancelled ? " (CANCELLED)" : "");
 682        }
 683        catch (Exception ex)
 684        {
 685            _logger.LogError(ex, "Failed to store match {HomeTeam} vs {AwayTeam}",
 686                match.HomeTeam, match.AwayTeam);
 687            throw;
 688        }
 689    }
 690
 691    private static Timestamp ConvertToTimestamp(ZonedDateTime zonedDateTime)
 692    {
 693        var instant = zonedDateTime.ToInstant();
 694        return Timestamp.FromDateTimeOffset(instant.ToDateTimeOffset());
 695    }
 696
 697    private static ZonedDateTime ConvertFromTimestamp(Timestamp timestamp)
 698    {
 699        var dateTimeOffset = timestamp.ToDateTimeOffset();
 700        var instant = Instant.FromDateTimeOffset(dateTimeOffset);
 701        return instant.InUtc();
 702    }
 703
 704    public async Task<int> GetMatchRepredictionIndexAsync(Match match, string model, string communityContext, Cancellati
 705    {
 706        try
 707        {
 708            // Query by match characteristics, model, community context, and competition
 709            // Order by repredictionIndex descending to get the latest version
 710            var query = _firestoreDb.Collection(_predictionsCollection)
 711                .WhereEqualTo("homeTeam", match.HomeTeam)
 712                .WhereEqualTo("awayTeam", match.AwayTeam)
 713                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 714                .WhereEqualTo("competition", _competition)
 715                .WhereEqualTo("model", model)
 716                .WhereEqualTo("communityContext", communityContext)
 717                .OrderByDescending("repredictionIndex")
 718                .Limit(1);
 719
 720            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 721
 722            if (snapshot.Documents.Count == 0)
 723            {
 724                return -1; // No prediction exists
 725            }
 726
 727            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 728            return firestorePrediction.RepredictionIndex;
 729        }
 730        catch (Exception ex)
 731        {
 732            _logger.LogError(ex, "Failed to get reprediction index for match {HomeTeam} vs {AwayTeam} using model {Model
 733                match.HomeTeam, match.AwayTeam, model, communityContext);
 734            throw;
 735        }
 736    }
 737
 738    // See IPredictionRepository.cs for detailed documentation on why these methods exist.
 739    // In short: cancelled matches have inconsistent startsAt values across different Kicktipp pages,
 740    // so we query by team names only to find predictions regardless of which startsAt was used.
 741
 742    /// <inheritdoc />
 743    public async Task<Prediction?> GetCancelledMatchPredictionAsync(string homeTeam, string awayTeam, string model, stri
 744    {
 745        try
 746        {
 747            // Query by team names only (no startsAt), ordered by createdAt descending to get the most recent
 748            // We use repredictionIndex descending first to get the latest reprediction, then createdAt for tiebreaking
 749            var query = _firestoreDb.Collection(_predictionsCollection)
 750                .WhereEqualTo("homeTeam", homeTeam)
 751                .WhereEqualTo("awayTeam", awayTeam)
 752                .WhereEqualTo("competition", _competition)
 753                .WhereEqualTo("model", model)
 754                .WhereEqualTo("communityContext", communityContext)
 755                .OrderByDescending("createdAt")
 756                .Limit(1);
 757
 758            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 759
 760            if (snapshot.Documents.Count == 0)
 761            {
 762                _logger.LogDebug("No prediction found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-only look
 763                return null;
 764            }
 765
 766            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 767            _logger.LogDebug("Found prediction for cancelled match {HomeTeam} vs {AwayTeam} with startsAt={StartsAt} (te
 768                homeTeam, awayTeam, firestorePrediction.StartsAt);
 769
 770            return new Prediction(
 771                firestorePrediction.HomeGoals,
 772                firestorePrediction.AwayGoals,
 773                DeserializeJustification(firestorePrediction.Justification));
 774        }
 775        catch (Exception ex)
 776        {
 777            _logger.LogError(ex, "Failed to get prediction for cancelled match {HomeTeam} vs {AwayTeam} using model {Mod
 778                homeTeam, awayTeam, model, communityContext);
 779            throw;
 780        }
 781    }
 782
 783    /// <inheritdoc />
 784    public async Task<PredictionMetadata?> GetCancelledMatchPredictionMetadataAsync(string homeTeam, string awayTeam, st
 785    {
 786        try
 787        {
 788            // Query by team names only (no startsAt), ordered by repredictionIndex descending to get the latest repredi
 789            var query = _firestoreDb.Collection(_predictionsCollection)
 790                .WhereEqualTo("homeTeam", homeTeam)
 791                .WhereEqualTo("awayTeam", awayTeam)
 792                .WhereEqualTo("competition", _competition)
 793                .WhereEqualTo("model", model)
 794                .WhereEqualTo("communityContext", communityContext)
 795                .OrderByDescending("repredictionIndex")
 796                .Limit(1);
 797
 798            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 799
 800            if (snapshot.Documents.Count == 0)
 801            {
 802                _logger.LogDebug("No prediction metadata found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-
 803                return null;
 804            }
 805
 806            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 807            _logger.LogDebug("Found prediction metadata for cancelled match {HomeTeam} vs {AwayTeam} with startsAt={Star
 808                homeTeam, awayTeam, firestorePrediction.StartsAt);
 809
 810            var prediction = new Prediction(
 811                firestorePrediction.HomeGoals,
 812                firestorePrediction.AwayGoals,
 813                DeserializeJustification(firestorePrediction.Justification));
 814            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 815            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 816
 817            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 818        }
 819        catch (Exception ex)
 820        {
 821            _logger.LogError(ex, "Failed to get prediction metadata for cancelled match {HomeTeam} vs {AwayTeam} using m
 822                homeTeam, awayTeam, model, communityContext);
 823            throw;
 824        }
 825    }
 826
 827    /// <inheritdoc />
 828    public async Task<int> GetCancelledMatchRepredictionIndexAsync(string homeTeam, string awayTeam, string model, strin
 829    {
 830        try
 831        {
 832            // Query by team names only (no startsAt), ordered by repredictionIndex descending to get the highest
 833            var query = _firestoreDb.Collection(_predictionsCollection)
 834                .WhereEqualTo("homeTeam", homeTeam)
 835                .WhereEqualTo("awayTeam", awayTeam)
 836                .WhereEqualTo("competition", _competition)
 837                .WhereEqualTo("model", model)
 838                .WhereEqualTo("communityContext", communityContext)
 839                .OrderByDescending("repredictionIndex")
 840                .Limit(1);
 841
 842            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 843
 844            if (snapshot.Documents.Count == 0)
 845            {
 846                _logger.LogDebug("No reprediction index found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-o
 847                return -1;
 848            }
 849
 850            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 851            _logger.LogDebug("Found reprediction index {Index} for cancelled match {HomeTeam} vs {AwayTeam} with startsA
 852                firestorePrediction.RepredictionIndex, homeTeam, awayTeam, firestorePrediction.StartsAt);
 853
 854            return firestorePrediction.RepredictionIndex;
 855        }
 856        catch (Exception ex)
 857        {
 858            _logger.LogError(ex, "Failed to get reprediction index for cancelled match {HomeTeam} vs {AwayTeam} using mo
 859                homeTeam, awayTeam, model, communityContext);
 860            throw;
 861        }
 862    }
 863
 864    public async Task<int> GetBonusRepredictionIndexAsync(string questionText, string model, string communityContext, Ca
 865    {
 866        try
 867        {
 868            // Query by question text, model, community context, and competition
 869            // Order by repredictionIndex descending to get the latest version
 870            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 871                .WhereEqualTo("questionText", questionText)
 872                .WhereEqualTo("competition", _competition)
 873                .WhereEqualTo("model", model)
 874                .WhereEqualTo("communityContext", communityContext)
 875                .OrderByDescending("repredictionIndex")
 876                .Limit(1);
 877
 878            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 879
 880            if (snapshot.Documents.Count == 0)
 881            {
 882                return -1; // No prediction exists
 883            }
 884
 885            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 886            return firestorePrediction.RepredictionIndex;
 887        }
 888        catch (Exception ex)
 889        {
 890            _logger.LogError(ex, "Failed to get reprediction index for bonus question '{QuestionText}' using model {Mode
 891                questionText, model, communityContext);
 892            throw;
 893        }
 894    }
 895
 896    public async Task SaveRepredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double 
 897    {
 898        try
 899        {
 900            var now = Timestamp.GetCurrentTimestamp();
 901
 902            // Create new document for this reprediction
 903            var documentId = Guid.NewGuid().ToString();
 904            var docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 905
 906            _logger.LogDebug("Creating reprediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, repredic
 907                match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 908
 909            var firestorePrediction = new FirestoreMatchPrediction
 910            {
 911                Id = docRef.Id,
 912                HomeTeam = match.HomeTeam,
 913                AwayTeam = match.AwayTeam,
 914                StartsAt = ConvertToTimestamp(match.StartsAt),
 915                Matchday = match.Matchday,
 916                HomeGoals = prediction.HomeGoals,
 917                AwayGoals = prediction.AwayGoals,
 918                Justification = SerializeJustification(prediction.Justification),
 919                CreatedAt = now,
 920                UpdatedAt = now,
 921                Competition = _competition,
 922                Model = model,
 923                TokenUsage = tokenUsage,
 924                Cost = cost,
 925                CommunityContext = communityContext,
 926                ContextDocumentNames = contextDocumentNames.ToArray(),
 927                RepredictionIndex = repredictionIndex
 928            };
 929
 930            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 931
 932            _logger.LogInformation("Saved reprediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repred
 933                match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 934        }
 935        catch (Exception ex)
 936        {
 937            _logger.LogError(ex, "Failed to save reprediction for match {HomeTeam} vs {AwayTeam}",
 938                match.HomeTeam, match.AwayTeam);
 939            throw;
 940        }
 941    }
 942
 943    public async Task SaveBonusRepredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mo
 944    {
 945        try
 946        {
 947            var now = Timestamp.GetCurrentTimestamp();
 948
 949            // Create new document for this reprediction
 950            var documentId = Guid.NewGuid().ToString();
 951            var docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 952
 953            _logger.LogDebug("Creating bonus reprediction for question '{QuestionText}' (document: {DocumentId}, repredi
 954                bonusQuestion.Text, documentId, repredictionIndex);
 955
 956            // Extract selected option texts for observability
 957            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 958            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 959                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 960                .ToArray();
 961
 962            var firestoreBonusPrediction = new FirestoreBonusPrediction
 963            {
 964                Id = docRef.Id,
 965                QuestionText = bonusQuestion.Text,
 966                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 967                SelectedOptionTexts = selectedOptionTexts,
 968                CreatedAt = now,
 969                UpdatedAt = now,
 970                Competition = _competition,
 971                Model = model,
 972                TokenUsage = tokenUsage,
 973                Cost = cost,
 974                CommunityContext = communityContext,
 975                ContextDocumentNames = contextDocumentNames.ToArray(),
 976                RepredictionIndex = repredictionIndex
 977            };
 978
 979            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 980
 981            _logger.LogInformation("Saved bonus reprediction for question '{QuestionText}' (reprediction index: {Repredi
 982                bonusQuestion.Text, repredictionIndex);
 983        }
 984        catch (Exception ex)
 985        {
 986            _logger.LogError(ex, "Failed to save bonus reprediction for question: {QuestionText}",
 987                bonusQuestion.Text);
 988            throw;
 989        }
 990    }
 991
 992    /// <summary>
 993    /// Get match prediction costs and counts grouped by reprediction index for cost analysis.
 994    /// Used specifically by the cost command to include all repredictions.
 995    /// </summary>
 996    public async Task<Dictionary<int, (double cost, int count)>> GetMatchPredictionCostsByRepredictionIndexAsync(
 997        string model,
 998        string communityContext,
 999        List<int>? matchdays = null,
 1000        CancellationToken cancellationToken = default)
 1001    {
 1002        try
 1003        {
 1004            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 1005
 1006            // Query for match predictions with cost data
 1007            var query = _firestoreDb.Collection(_predictionsCollection)
 1008                .WhereEqualTo("competition", _competition)
 1009                .WhereEqualTo("model", model)
 1010                .WhereEqualTo("communityContext", communityContext);
 1011
 1012            // Add matchday filter if specified
 1013            if (matchdays?.Count > 0)
 1014            {
 1015                query = query.WhereIn("matchday", matchdays.Cast<object>().ToArray());
 1016            }
 1017
 1018            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1019
 1020            foreach (var doc in snapshot.Documents)
 1021            {
 1022                if (doc.Exists)
 1023                {
 1024                    var prediction = doc.ConvertTo<FirestoreMatchPrediction>();
 1025                    var repredictionIndex = prediction.RepredictionIndex;
 1026
 1027                    if (!costsByIndex.ContainsKey(repredictionIndex))
 1028                    {
 1029                        costsByIndex[repredictionIndex] = (0.0, 0);
 1030                    }
 1031
 1032                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 1033                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 1034                }
 1035            }
 1036
 1037            return costsByIndex;
 1038        }
 1039        catch (Exception ex)
 1040        {
 1041            _logger.LogError(ex, "Failed to get match prediction costs by reprediction index for model {Model} and commu
 1042                model, communityContext);
 1043            throw;
 1044        }
 1045    }
 1046
 1047    /// <summary>
 1048    /// Get bonus prediction costs and counts grouped by reprediction index for cost analysis.
 1049    /// Used specifically by the cost command to include all repredictions.
 1050    /// </summary>
 1051    public async Task<Dictionary<int, (double cost, int count)>> GetBonusPredictionCostsByRepredictionIndexAsync(
 1052        string model,
 1053        string communityContext,
 1054        CancellationToken cancellationToken = default)
 1055    {
 1056        try
 1057        {
 1058            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 1059
 1060            // Query for bonus predictions with cost data
 1061            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1062                .WhereEqualTo("competition", _competition)
 1063                .WhereEqualTo("model", model)
 1064                .WhereEqualTo("communityContext", communityContext);
 1065
 1066            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1067
 1068            foreach (var doc in snapshot.Documents)
 1069            {
 1070                if (doc.Exists)
 1071                {
 1072                    var prediction = doc.ConvertTo<FirestoreBonusPrediction>();
 1073                    var repredictionIndex = prediction.RepredictionIndex;
 1074
 1075                    if (!costsByIndex.ContainsKey(repredictionIndex))
 1076                    {
 1077                        costsByIndex[repredictionIndex] = (0.0, 0);
 1078                    }
 1079
 1080                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 1081                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 1082                }
 1083            }
 1084
 1085            return costsByIndex;
 1086        }
 1087        catch (Exception ex)
 1088        {
 1089            _logger.LogError(ex, "Failed to get bonus prediction costs by reprediction index for model {Model} and commu
 1090                model, communityContext);
 1091            throw;
 1092        }
 1093    }
 1094
 1095    /// <inheritdoc />
 1096    public async Task<List<int>> GetAvailableMatchdaysAsync(CancellationToken cancellationToken = default)
 1097    {
 1098        try
 1099        {
 1100            var matchdays = new HashSet<int>();
 1101
 1102            // Query match predictions for unique matchdays
 1103            var query = _firestoreDb.Collection(_predictionsCollection)
 1104                .WhereEqualTo("competition", _competition);
 1105            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1106
 1107            foreach (var doc in snapshot.Documents)
 1108            {
 1109                if (doc.TryGetValue<int>("matchday", out var matchday) && matchday > 0)
 1110                {
 1111                    matchdays.Add(matchday);
 1112                }
 1113            }
 1114
 1115            return matchdays.OrderBy(m => m).ToList();
 1116        }
 1117        catch (Exception ex)
 1118        {
 1119            _logger.LogError(ex, "Failed to get available matchdays");
 1120            throw;
 1121        }
 1122    }
 1123
 1124    /// <inheritdoc />
 1125    public async Task<List<string>> GetAvailableModelsAsync(CancellationToken cancellationToken = default)
 1126    {
 1127        try
 1128        {
 1129            var models = new HashSet<string>();
 1130
 1131            // Query match predictions for unique models
 1132            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 1133                .WhereEqualTo("competition", _competition);
 1134            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1135
 1136            foreach (var doc in matchSnapshot.Documents)
 1137            {
 1138                if (doc.TryGetValue<string>("model", out var model) && !string.IsNullOrWhiteSpace(model))
 1139                {
 1140                    models.Add(model);
 1141                }
 1142            }
 1143
 1144            // Query bonus predictions for unique models
 1145            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 1146                .WhereEqualTo("competition", _competition);
 1147            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1148
 1149            foreach (var doc in bonusSnapshot.Documents)
 1150            {
 1151                if (doc.TryGetValue<string>("model", out var model) && !string.IsNullOrWhiteSpace(model))
 1152                {
 1153                    models.Add(model);
 1154                }
 1155            }
 1156
 1157            return models.OrderBy(model => model, StringComparer.Ordinal).ToList();
 1158        }
 1159        catch (Exception ex)
 1160        {
 1161            _logger.LogError(ex, "Failed to get available models");
 1162            throw;
 1163        }
 1164    }
 1165
 1166    /// <inheritdoc />
 1167    public async Task<List<string>> GetAvailableCommunityContextsAsync(CancellationToken cancellationToken = default)
 1168    {
 1169        try
 1170        {
 1171            var communityContexts = new HashSet<string>();
 1172
 1173            // Query match predictions for unique community contexts
 1174            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 1175                .WhereEqualTo("competition", _competition);
 1176            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1177
 1178            foreach (var doc in matchSnapshot.Documents)
 1179            {
 1180                if (doc.TryGetValue<string>("communityContext", out var context) && !string.IsNullOrWhiteSpace(context))
 1181                {
 1182                    communityContexts.Add(context);
 1183                }
 1184            }
 1185
 1186            // Query bonus predictions for unique community contexts
 1187            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 1188                .WhereEqualTo("competition", _competition);
 1189            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1190
 1191            foreach (var doc in bonusSnapshot.Documents)
 1192            {
 1193                if (doc.TryGetValue<string>("communityContext", out var context) && !string.IsNullOrWhiteSpace(context))
 1194                {
 1195                    communityContexts.Add(context);
 1196                }
 1197            }
 1198
 1199            return communityContexts.OrderBy(context => context, StringComparer.Ordinal).ToList();
 1200        }
 1201        catch (Exception ex)
 1202        {
 1203            _logger.LogError(ex, "Failed to get available community contexts");
 1204            throw;
 1205        }
 1206    }
 1207
 1208    private string? SerializeJustification(PredictionJustification? justification)
 1209    {
 1210        if (justification == null)
 1211        {
 1212            return null;
 1213        }
 1214
 1215        if (!HasJustificationContent(justification))
 1216        {
 1217            return null;
 1218        }
 1219
 1220        var stored = new StoredJustification
 1221        {
 1222            KeyReasoning = justification.KeyReasoning?.Trim() ?? string.Empty,
 1223            ContextSources = new StoredContextSources
 1224            {
 1225                MostValuable = justification.ContextSources?.MostValuable?
 1226                    .Where(entry => entry != null)
 1227                    .Select(ToStoredContextSource)
 1228                    .ToList() ?? new List<StoredContextSource>(),
 1229                LeastValuable = justification.ContextSources?.LeastValuable?
 1230                    .Where(entry => entry != null)
 1231                    .Select(ToStoredContextSource)
 1232                    .ToList() ?? new List<StoredContextSource>()
 1233            },
 1234            Uncertainties = justification.Uncertainties?
 1235                .Where(item => !string.IsNullOrWhiteSpace(item))
 1236                .Select(item => item.Trim())
 1237                .ToList() ?? new List<string>()
 1238        };
 1239
 1240        return JsonSerializer.Serialize(stored, JustificationSerializerOptions);
 1241    }
 1242
 1243    private static bool HasJustificationContent(PredictionJustification justification)
 1244    {
 1245        if (!string.IsNullOrWhiteSpace(justification.KeyReasoning))
 1246        {
 1247            return true;
 1248        }
 1249
 1250        if (justification.ContextSources?.MostValuable != null &&
 1251            justification.ContextSources.MostValuable.Any(HasSourceContent))
 1252        {
 1253            return true;
 1254        }
 1255
 1256        if (justification.ContextSources?.LeastValuable != null &&
 1257            justification.ContextSources.LeastValuable.Any(HasSourceContent))
 1258        {
 1259            return true;
 1260        }
 1261
 1262        return justification.Uncertainties != null &&
 1263               justification.Uncertainties.Any(item => !string.IsNullOrWhiteSpace(item));
 1264    }
 1265
 1266    private static bool HasSourceContent(PredictionJustificationContextSource source)
 1267    {
 1268        return !string.IsNullOrWhiteSpace(source?.DocumentName) ||
 1269               !string.IsNullOrWhiteSpace(source?.Details);
 1270    }
 1271
 1272    private PredictionJustification? DeserializeJustification(string? serialized)
 1273    {
 1274        if (string.IsNullOrWhiteSpace(serialized))
 1275        {
 1276            return null;
 1277        }
 1278
 1279        var trimmed = serialized.Trim();
 1280
 1281        if (!trimmed.StartsWith("{"))
 1282        {
 1283            return new PredictionJustification(
 1284                trimmed,
 1285                new PredictionJustificationContextSources(
 1286                    Array.Empty<PredictionJustificationContextSource>(),
 1287                    Array.Empty<PredictionJustificationContextSource>()),
 1288                Array.Empty<string>());
 1289        }
 1290
 1291        try
 1292        {
 1293            var stored = JsonSerializer.Deserialize<StoredJustification>(trimmed, JustificationSerializerOptions);
 1294
 1295            if (stored == null)
 1296            {
 1297                return null;
 1298            }
 1299
 1300            var contextSources = stored.ContextSources ?? new StoredContextSources();
 1301
 1302            var mostValuable = contextSources.MostValuable?
 1303                .Where(entry => entry != null)
 1304                .Select(ToDomainContextSource)
 1305                .ToList() ?? new List<PredictionJustificationContextSource>();
 1306
 1307            var leastValuable = contextSources.LeastValuable?
 1308                .Where(entry => entry != null)
 1309                .Select(ToDomainContextSource)
 1310                .ToList() ?? new List<PredictionJustificationContextSource>();
 1311
 1312            var uncertainties = stored.Uncertainties?
 1313                .Where(item => !string.IsNullOrWhiteSpace(item))
 1314                .Select(item => item.Trim())
 1315                .ToList() ?? new List<string>();
 1316
 1317            var justification = new PredictionJustification(
 1318                stored.KeyReasoning?.Trim() ?? string.Empty,
 1319                new PredictionJustificationContextSources(mostValuable, leastValuable),
 1320                uncertainties);
 1321
 1322            return HasJustificationContent(justification) ? justification : null;
 1323        }
 1324        catch (JsonException ex)
 1325        {
 1326            _logger.LogWarning(ex, "Failed to parse structured justification JSON; falling back to legacy text format");
 1327
 1328            var fallbackJustification = new PredictionJustification(
 1329                trimmed,
 1330                new PredictionJustificationContextSources(
 1331                    Array.Empty<PredictionJustificationContextSource>(),
 1332                    Array.Empty<PredictionJustificationContextSource>()),
 1333                Array.Empty<string>());
 1334
 1335            return HasJustificationContent(fallbackJustification) ? fallbackJustification : null;
 1336        }
 1337    }
 1338
 1339    private static StoredContextSource ToStoredContextSource(PredictionJustificationContextSource source)
 1340    {
 1341        return new StoredContextSource
 1342        {
 1343            DocumentName = source.DocumentName?.Trim() ?? string.Empty,
 1344            Details = source.Details?.Trim() ?? string.Empty
 1345        };
 1346    }
 1347
 1348    private static PredictionJustificationContextSource ToDomainContextSource(StoredContextSource source)
 1349    {
 1350        var documentName = source.DocumentName?.Trim() ?? string.Empty;
 1351        var details = source.Details?.Trim() ?? string.Empty;
 1352        return new PredictionJustificationContextSource(documentName, details);
 1353    }
 1354
 1355    private sealed class StoredJustification
 1356    {
 1357        public string? KeyReasoning { get; set; }
 1358        public StoredContextSources? ContextSources { get; set; }
 1359        public List<string>? Uncertainties { get; set; }
 1360    }
 1361
 1362    private sealed class StoredContextSources
 1363    {
 1364        public List<StoredContextSource>? MostValuable { get; set; }
 1365        public List<StoredContextSource>? LeastValuable { get; set; }
 1366    }
 1367
 1368    private sealed class StoredContextSource
 1369    {
 11370        public string? DocumentName { get; set; }
 11371        public string? Details { get; set; }
 1372    }
 1373}