< 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: 1042
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            var query = _firestoreDb.Collection(_predictionsCollection)
 181                .WhereEqualTo("homeTeam", match.HomeTeam)
 182                .WhereEqualTo("awayTeam", match.AwayTeam)
 183                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 184                .WhereEqualTo("competition", _competition)
 185                .WhereEqualTo("model", model)
 186                .WhereEqualTo("communityContext", communityContext);
 187
 188            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 189
 190            if (snapshot.Documents.Count == 0)
 191            {
 192                return null;
 193            }
 194
 195            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 196            var prediction = new Prediction(
 197                firestorePrediction.HomeGoals,
 198                firestorePrediction.AwayGoals,
 199                DeserializeJustification(firestorePrediction.Justification));
 200            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 201            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 202
 203            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 204        }
 205        catch (Exception ex)
 206        {
 207            _logger.LogError(ex, "Failed to get prediction metadata for match {HomeTeam} vs {AwayTeam} using model {Mode
 208                match.HomeTeam, match.AwayTeam, model, communityContext);
 209            throw;
 210        }
 211    }
 212
 213    public async Task<IReadOnlyList<Match>> GetMatchDayAsync(int matchDay, CancellationToken cancellationToken = default
 214    {
 215        try
 216        {
 217            var query = _firestoreDb.Collection(_matchesCollection)
 218                .WhereEqualTo("competition", _competition)
 219                .WhereEqualTo("matchday", matchDay)
 220                .OrderBy("startsAt");
 221
 222            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 223
 224            var matches = snapshot.Documents
 225                .Select(doc => doc.ConvertTo<FirestoreMatch>())
 226                .Select(fm => new Match(
 227                    fm.HomeTeam,
 228                    fm.AwayTeam,
 229                    ConvertFromTimestamp(fm.StartsAt),
 230                    fm.Matchday,
 231                    fm.IsCancelled))
 232                .ToList();
 233
 234            return matches.AsReadOnly();
 235        }
 236        catch (Exception ex)
 237        {
 238            _logger.LogError(ex, "Failed to get matches for matchday {Matchday}", matchDay);
 239            throw;
 240        }
 241    }
 242
 243    public async Task<IReadOnlyList<MatchPrediction>> GetMatchDayWithPredictionsAsync(int matchDay, string model, string
 244    {
 245        try
 246        {
 247            // Get all matches for the matchday
 248            var matches = await GetMatchDayAsync(matchDay, cancellationToken);
 249
 250            // Get predictions for all matches using the specified model and community context
 251            var matchPredictions = new List<MatchPrediction>();
 252
 253            foreach (var match in matches)
 254            {
 255                var prediction = await GetPredictionAsync(match, model, communityContext, cancellationToken);
 256                matchPredictions.Add(new MatchPrediction(match, prediction));
 257            }
 258
 259            return matchPredictions.AsReadOnly();
 260        }
 261        catch (Exception ex)
 262        {
 263            _logger.LogError(ex, "Failed to get matches with predictions for matchday {Matchday} using model {Model} and
 264            throw;
 265        }
 266    }
 267
 268    public async Task<IReadOnlyList<MatchPrediction>> GetAllPredictionsAsync(string model, string communityContext, Canc
 269    {
 270        try
 271        {
 272            var query = _firestoreDb.Collection(_predictionsCollection)
 273                .WhereEqualTo("competition", _competition)
 274                .WhereEqualTo("model", model)
 275                .WhereEqualTo("communityContext", communityContext)
 276                .OrderBy("matchday");
 277
 278            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 279
 280            var matchPredictions = snapshot.Documents
 281                .Select(doc => doc.ConvertTo<FirestoreMatchPrediction>())
 282                .Select(fp => new MatchPrediction(
 283                    new Match(fp.HomeTeam, fp.AwayTeam, ConvertFromTimestamp(fp.StartsAt), fp.Matchday),
 284                    new Prediction(
 285                        fp.HomeGoals,
 286                        fp.AwayGoals,
 287                        DeserializeJustification(fp.Justification))))
 288                .ToList();
 289
 290            return matchPredictions.AsReadOnly();
 291        }
 292        catch (Exception ex)
 293        {
 294            _logger.LogError(ex, "Failed to get all predictions for model {Model} and community context {CommunityContex
 295            throw;
 296        }
 297    }
 298
 299    public async Task<bool> HasPredictionAsync(Match match, string model, string communityContext, CancellationToken can
 300    {
 301        try
 302        {
 303            // Query by match characteristics, model, and community context instead of using deterministic ID
 304            var query = _firestoreDb.Collection(_predictionsCollection)
 305                .WhereEqualTo("homeTeam", match.HomeTeam)
 306                .WhereEqualTo("awayTeam", match.AwayTeam)
 307                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 308                .WhereEqualTo("competition", _competition)
 309                .WhereEqualTo("model", model)
 310                .WhereEqualTo("communityContext", communityContext);
 311
 312            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 313            return snapshot.Documents.Count > 0;
 314        }
 315        catch (Exception ex)
 316        {
 317            _logger.LogError(ex, "Failed to check if prediction exists for match {HomeTeam} vs {AwayTeam} using model {M
 318                match.HomeTeam, match.AwayTeam, model, communityContext);
 319            throw;
 320        }
 321    }
 322
 323    public async Task SaveBonusPredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mode
 324    {
 325        try
 326        {
 327            var now = Timestamp.GetCurrentTimestamp();
 328
 329            // Check if a prediction already exists for this question, model, and community context
 330            // Order by repredictionIndex descending to get the latest version for updating
 331            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 332                .WhereEqualTo("questionText", bonusQuestion.Text)
 333                .WhereEqualTo("competition", _competition)
 334                .WhereEqualTo("model", model)
 335                .WhereEqualTo("communityContext", communityContext)
 336                .OrderByDescending("repredictionIndex")
 337                .Limit(1);
 338
 339            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 340
 341            DocumentReference docRef;
 342            bool isUpdate = false;
 343            Timestamp? existingCreatedAt = null;
 344            int repredictionIndex = 0;
 345
 346            if (snapshot.Documents.Count > 0)
 347            {
 348                // Update existing document (latest reprediction)
 349                var existingDoc = snapshot.Documents.First();
 350                docRef = existingDoc.Reference;
 351                isUpdate = true;
 352
 353                // Preserve the original values
 354                var existingData = existingDoc.ConvertTo<FirestoreBonusPrediction>();
 355                existingCreatedAt = existingData.CreatedAt;
 356                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 357
 358                _logger.LogDebug("Updating existing bonus prediction for question '{QuestionText}' (document: {DocumentI
 359                    bonusQuestion.Text, existingDoc.Id, repredictionIndex);
 360            }
 361            else
 362            {
 363                // Create new document
 364                var documentId = Guid.NewGuid().ToString();
 365                docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 366                repredictionIndex = 0; // First prediction
 367
 368                _logger.LogDebug("Creating new bonus prediction for question '{QuestionText}' (document: {DocumentId}, r
 369                    bonusQuestion.Text, documentId, repredictionIndex);
 370            }
 371
 372            // Extract selected option texts for observability
 373            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 374            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 375                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 376                .ToArray();
 377
 378            var firestoreBonusPrediction = new FirestoreBonusPrediction
 379            {
 380                Id = docRef.Id,
 381                QuestionText = bonusQuestion.Text,
 382                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 383                SelectedOptionTexts = selectedOptionTexts,
 384                UpdatedAt = now,
 385                Competition = _competition,
 386                Model = model,
 387                TokenUsage = tokenUsage,
 388                Cost = cost,
 389                CommunityContext = communityContext,
 390                ContextDocumentNames = contextDocumentNames.ToArray(),
 391                RepredictionIndex = repredictionIndex
 392            };
 393
 394            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 395            firestoreBonusPrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreate
 396
 397            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 398
 399            var action = isUpdate ? "Updated" : "Saved";
 400            _logger.LogDebug("{Action} bonus prediction for question '{QuestionText}' with selections: {SelectedOptions}
 401                action, bonusQuestion.Text, string.Join(", ", selectedOptionTexts), repredictionIndex);
 402        }
 403        catch (Exception ex)
 404        {
 405            _logger.LogError(ex, "Failed to save bonus prediction for question: {QuestionText}",
 406                bonusQuestion.Text);
 407            throw;
 408        }
 409    }
 410
 411    public async Task<BonusPrediction?> GetBonusPredictionAsync(string questionId, string model, string communityContext
 412    {
 413        try
 414        {
 415            // Query by questionId, model, community context, and competition instead of using direct document lookup
 416            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 417                .WhereEqualTo("questionId", questionId)
 418                .WhereEqualTo("competition", _competition)
 419                .WhereEqualTo("model", model)
 420                .WhereEqualTo("communityContext", communityContext);
 421
 422            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 423
 424            if (snapshot.Documents.Count == 0)
 425            {
 426                return null;
 427            }
 428
 429            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 430            return new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 431        }
 432        catch (Exception ex)
 433        {
 434            _logger.LogError(ex, "Failed to get bonus prediction for question {QuestionId} using model {Model} and commu
 435            throw;
 436        }
 437    }
 438
 439    public async Task<BonusPrediction?> GetBonusPredictionByTextAsync(string questionText, string model, string communit
 440    {
 441        try
 442        {
 443            // Query by questionText, model, and community context
 444            // Order by repredictionIndex descending to get the latest version
 445            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 446                .WhereEqualTo("questionText", questionText)
 447                .WhereEqualTo("competition", _competition)
 448                .WhereEqualTo("model", model)
 449                .WhereEqualTo("communityContext", communityContext)
 450                .OrderByDescending("repredictionIndex")
 451                .Limit(1);
 452
 453            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 454
 455            if (snapshot.Documents.Count == 0)
 456            {
 457                _logger.LogDebug("No bonus prediction found for question text: {QuestionText} with model: {Model} and co
 458                return null;
 459            }
 460
 461            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 462            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 463
 464            _logger.LogDebug("Found bonus prediction for question text: {QuestionText} with model: {Model} and community
 465                questionText, model, communityContext, firestoreBonusPrediction.RepredictionIndex);
 466
 467            return bonusPrediction;
 468        }
 469        catch (Exception ex)
 470        {
 471            _logger.LogError(ex, "Failed to retrieve bonus prediction by text: {QuestionText} with model: {Model} and co
 472            throw;
 473        }
 474    }
 475
 476    public async Task<BonusPredictionMetadata?> GetBonusPredictionMetadataByTextAsync(string questionText, string model,
 477    {
 478        try
 479        {
 480            // Query by questionText, model, and community context
 481            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 482                .WhereEqualTo("questionText", questionText)
 483                .WhereEqualTo("competition", _competition)
 484                .WhereEqualTo("model", model)
 485                .WhereEqualTo("communityContext", communityContext)
 486                .Limit(1);
 487
 488            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 489
 490            if (snapshot.Documents.Count == 0)
 491            {
 492                _logger.LogDebug("No bonus prediction metadata found for question text: {QuestionText} with model: {Mode
 493                return null;
 494            }
 495
 496            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 497            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 498            var createdAt = firestoreBonusPrediction.CreatedAt.ToDateTimeOffset();
 499            var contextDocumentNames = firestoreBonusPrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 500
 501            _logger.LogDebug("Found bonus prediction metadata for question text: {QuestionText} with model: {Model} and 
 502                questionText, model, communityContext);
 503
 504            return new BonusPredictionMetadata(bonusPrediction, createdAt, contextDocumentNames);
 505        }
 506        catch (Exception ex)
 507        {
 508            _logger.LogError(ex, "Failed to retrieve bonus prediction metadata by text: {QuestionText} with model: {Mode
 509            throw;
 510        }
 511    }
 512
 513    public async Task<IReadOnlyList<BonusPrediction>> GetAllBonusPredictionsAsync(string model, string communityContext,
 514    {
 515        try
 516        {
 517            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 518                .WhereEqualTo("competition", _competition)
 519                .WhereEqualTo("model", model)
 520                .WhereEqualTo("communityContext", communityContext)
 521                .OrderBy("createdAt");
 522
 523            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 524
 525            var bonusPredictions = new List<BonusPrediction>();
 526            foreach (var document in snapshot.Documents)
 527            {
 528                var firestoreBonusPrediction = document.ConvertTo<FirestoreBonusPrediction>();
 529                bonusPredictions.Add(new BonusPrediction(
 530                    firestoreBonusPrediction.SelectedOptionIds.ToList()));
 531            }
 532
 533            return bonusPredictions.AsReadOnly();
 534        }
 535        catch (Exception ex)
 536        {
 537            _logger.LogError(ex, "Failed to get all bonus predictions for model {Model} and community context {Community
 538            throw;
 539        }
 540    }
 541
 542    public async Task<bool> HasBonusPredictionAsync(string questionId, string model, string communityContext, Cancellati
 543    {
 544        try
 545        {
 546            // Query by questionId, model, and community context instead of using direct document lookup
 547            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 548                .WhereEqualTo("questionId", questionId)
 549                .WhereEqualTo("competition", _competition)
 550                .WhereEqualTo("model", model)
 551                .WhereEqualTo("communityContext", communityContext);
 552
 553            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 554            return snapshot.Documents.Count > 0;
 555        }
 556        catch (Exception ex)
 557        {
 558            _logger.LogError(ex, "Failed to check if bonus prediction exists for question {QuestionId} using model {Mode
 559            throw;
 560        }
 561    }
 562
 563    /// <summary>
 564    /// Stores a match in the matches collection for matchday management.
 565    /// This is typically called when importing match schedules.
 566    /// </summary>
 567    public async Task StoreMatchAsync(Match match, CancellationToken cancellationToken = default)
 568    {
 569        try
 570        {
 571            var documentId = Guid.NewGuid().ToString();
 572
 573            var firestoreMatch = new FirestoreMatch
 574            {
 575                Id = documentId,
 576                HomeTeam = match.HomeTeam,
 577                AwayTeam = match.AwayTeam,
 578                StartsAt = ConvertToTimestamp(match.StartsAt),
 579                Matchday = match.Matchday,
 580                Competition = _competition,
 581                IsCancelled = match.IsCancelled
 582            };
 583
 584            await _firestoreDb.Collection(_matchesCollection)
 585                .Document(documentId)
 586                .SetAsync(firestoreMatch, cancellationToken: cancellationToken);
 587
 588            _logger.LogDebug("Stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}{Cancelled}",
 589                match.HomeTeam, match.AwayTeam, match.Matchday, match.IsCancelled ? " (CANCELLED)" : "");
 590        }
 591        catch (Exception ex)
 592        {
 593            _logger.LogError(ex, "Failed to store match {HomeTeam} vs {AwayTeam}",
 594                match.HomeTeam, match.AwayTeam);
 595            throw;
 596        }
 597    }
 598
 599    private static Timestamp ConvertToTimestamp(ZonedDateTime zonedDateTime)
 600    {
 601        var instant = zonedDateTime.ToInstant();
 602        return Timestamp.FromDateTimeOffset(instant.ToDateTimeOffset());
 603    }
 604
 605    private static ZonedDateTime ConvertFromTimestamp(Timestamp timestamp)
 606    {
 607        var dateTimeOffset = timestamp.ToDateTimeOffset();
 608        var instant = Instant.FromDateTimeOffset(dateTimeOffset);
 609        return instant.InUtc();
 610    }
 611
 612    public async Task<int> GetMatchRepredictionIndexAsync(Match match, string model, string communityContext, Cancellati
 613    {
 614        try
 615        {
 616            // Query by match characteristics, model, community context, and competition
 617            // Order by repredictionIndex descending to get the latest version
 618            var query = _firestoreDb.Collection(_predictionsCollection)
 619                .WhereEqualTo("homeTeam", match.HomeTeam)
 620                .WhereEqualTo("awayTeam", match.AwayTeam)
 621                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 622                .WhereEqualTo("competition", _competition)
 623                .WhereEqualTo("model", model)
 624                .WhereEqualTo("communityContext", communityContext)
 625                .OrderByDescending("repredictionIndex")
 626                .Limit(1);
 627
 628            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 629
 630            if (snapshot.Documents.Count == 0)
 631            {
 632                return -1; // No prediction exists
 633            }
 634
 635            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 636            return firestorePrediction.RepredictionIndex;
 637        }
 638        catch (Exception ex)
 639        {
 640            _logger.LogError(ex, "Failed to get reprediction index for match {HomeTeam} vs {AwayTeam} using model {Model
 641                match.HomeTeam, match.AwayTeam, model, communityContext);
 642            throw;
 643        }
 644    }
 645
 646    public async Task<int> GetBonusRepredictionIndexAsync(string questionText, string model, string communityContext, Ca
 647    {
 648        try
 649        {
 650            // Query by question text, model, community context, and competition
 651            // Order by repredictionIndex descending to get the latest version
 652            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 653                .WhereEqualTo("questionText", questionText)
 654                .WhereEqualTo("competition", _competition)
 655                .WhereEqualTo("model", model)
 656                .WhereEqualTo("communityContext", communityContext)
 657                .OrderByDescending("repredictionIndex")
 658                .Limit(1);
 659
 660            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 661
 662            if (snapshot.Documents.Count == 0)
 663            {
 664                return -1; // No prediction exists
 665            }
 666
 667            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 668            return firestorePrediction.RepredictionIndex;
 669        }
 670        catch (Exception ex)
 671        {
 672            _logger.LogError(ex, "Failed to get reprediction index for bonus question '{QuestionText}' using model {Mode
 673                questionText, model, communityContext);
 674            throw;
 675        }
 676    }
 677
 678    public async Task SaveRepredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double 
 679    {
 680        try
 681        {
 682            var now = Timestamp.GetCurrentTimestamp();
 683
 684            // Create new document for this reprediction
 685            var documentId = Guid.NewGuid().ToString();
 686            var docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 687
 688            _logger.LogDebug("Creating reprediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, repredic
 689                match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 690
 691            var firestorePrediction = new FirestoreMatchPrediction
 692            {
 693                Id = docRef.Id,
 694                HomeTeam = match.HomeTeam,
 695                AwayTeam = match.AwayTeam,
 696                StartsAt = ConvertToTimestamp(match.StartsAt),
 697                Matchday = match.Matchday,
 698                HomeGoals = prediction.HomeGoals,
 699                AwayGoals = prediction.AwayGoals,
 700                Justification = SerializeJustification(prediction.Justification),
 701                CreatedAt = now,
 702                UpdatedAt = now,
 703                Competition = _competition,
 704                Model = model,
 705                TokenUsage = tokenUsage,
 706                Cost = cost,
 707                CommunityContext = communityContext,
 708                ContextDocumentNames = contextDocumentNames.ToArray(),
 709                RepredictionIndex = repredictionIndex
 710            };
 711
 712            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 713
 714            _logger.LogInformation("Saved reprediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repred
 715                match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 716        }
 717        catch (Exception ex)
 718        {
 719            _logger.LogError(ex, "Failed to save reprediction for match {HomeTeam} vs {AwayTeam}",
 720                match.HomeTeam, match.AwayTeam);
 721            throw;
 722        }
 723    }
 724
 725    public async Task SaveBonusRepredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mo
 726    {
 727        try
 728        {
 729            var now = Timestamp.GetCurrentTimestamp();
 730
 731            // Create new document for this reprediction
 732            var documentId = Guid.NewGuid().ToString();
 733            var docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 734
 735            _logger.LogDebug("Creating bonus reprediction for question '{QuestionText}' (document: {DocumentId}, repredi
 736                bonusQuestion.Text, documentId, repredictionIndex);
 737
 738            // Extract selected option texts for observability
 739            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 740            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 741                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 742                .ToArray();
 743
 744            var firestoreBonusPrediction = new FirestoreBonusPrediction
 745            {
 746                Id = docRef.Id,
 747                QuestionText = bonusQuestion.Text,
 748                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 749                SelectedOptionTexts = selectedOptionTexts,
 750                CreatedAt = now,
 751                UpdatedAt = now,
 752                Competition = _competition,
 753                Model = model,
 754                TokenUsage = tokenUsage,
 755                Cost = cost,
 756                CommunityContext = communityContext,
 757                ContextDocumentNames = contextDocumentNames.ToArray(),
 758                RepredictionIndex = repredictionIndex
 759            };
 760
 761            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 762
 763            _logger.LogInformation("Saved bonus reprediction for question '{QuestionText}' (reprediction index: {Repredi
 764                bonusQuestion.Text, repredictionIndex);
 765        }
 766        catch (Exception ex)
 767        {
 768            _logger.LogError(ex, "Failed to save bonus reprediction for question: {QuestionText}",
 769                bonusQuestion.Text);
 770            throw;
 771        }
 772    }
 773
 774    /// <summary>
 775    /// Get match prediction costs and counts grouped by reprediction index for cost analysis.
 776    /// Used specifically by the cost command to include all repredictions.
 777    /// </summary>
 778    public async Task<Dictionary<int, (double cost, int count)>> GetMatchPredictionCostsByRepredictionIndexAsync(
 779        string model,
 780        string communityContext,
 781        List<int>? matchdays = null,
 782        CancellationToken cancellationToken = default)
 783    {
 784        try
 785        {
 786            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 787
 788            // Query for match predictions with cost data
 789            var query = _firestoreDb.Collection(_predictionsCollection)
 790                .WhereEqualTo("competition", _competition)
 791                .WhereEqualTo("model", model)
 792                .WhereEqualTo("communityContext", communityContext);
 793
 794            // Add matchday filter if specified
 795            if (matchdays?.Count > 0)
 796            {
 797                query = query.WhereIn("matchday", matchdays.Cast<object>().ToArray());
 798            }
 799
 800            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 801
 802            foreach (var doc in snapshot.Documents)
 803            {
 804                if (doc.Exists)
 805                {
 806                    var prediction = doc.ConvertTo<FirestoreMatchPrediction>();
 807                    var repredictionIndex = prediction.RepredictionIndex;
 808
 809                    if (!costsByIndex.ContainsKey(repredictionIndex))
 810                    {
 811                        costsByIndex[repredictionIndex] = (0.0, 0);
 812                    }
 813
 814                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 815                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 816                }
 817            }
 818
 819            return costsByIndex;
 820        }
 821        catch (Exception ex)
 822        {
 823            _logger.LogError(ex, "Failed to get match prediction costs by reprediction index for model {Model} and commu
 824                model, communityContext);
 825            throw;
 826        }
 827    }
 828
 829    /// <summary>
 830    /// Get bonus prediction costs and counts grouped by reprediction index for cost analysis.
 831    /// Used specifically by the cost command to include all repredictions.
 832    /// </summary>
 833    public async Task<Dictionary<int, (double cost, int count)>> GetBonusPredictionCostsByRepredictionIndexAsync(
 834        string model,
 835        string communityContext,
 836        CancellationToken cancellationToken = default)
 837    {
 838        try
 839        {
 840            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 841
 842            // Query for bonus predictions with cost data
 843            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 844                .WhereEqualTo("competition", _competition)
 845                .WhereEqualTo("model", model)
 846                .WhereEqualTo("communityContext", communityContext);
 847
 848            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 849
 850            foreach (var doc in snapshot.Documents)
 851            {
 852                if (doc.Exists)
 853                {
 854                    var prediction = doc.ConvertTo<FirestoreBonusPrediction>();
 855                    var repredictionIndex = prediction.RepredictionIndex;
 856
 857                    if (!costsByIndex.ContainsKey(repredictionIndex))
 858                    {
 859                        costsByIndex[repredictionIndex] = (0.0, 0);
 860                    }
 861
 862                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 863                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 864                }
 865            }
 866
 867            return costsByIndex;
 868        }
 869        catch (Exception ex)
 870        {
 871            _logger.LogError(ex, "Failed to get bonus prediction costs by reprediction index for model {Model} and commu
 872                model, communityContext);
 873            throw;
 874        }
 875    }
 876
 877    private string? SerializeJustification(PredictionJustification? justification)
 878    {
 879        if (justification == null)
 880        {
 881            return null;
 882        }
 883
 884        if (!HasJustificationContent(justification))
 885        {
 886            return null;
 887        }
 888
 889        var stored = new StoredJustification
 890        {
 891            KeyReasoning = justification.KeyReasoning?.Trim() ?? string.Empty,
 892            ContextSources = new StoredContextSources
 893            {
 894                MostValuable = justification.ContextSources?.MostValuable?
 895                    .Where(entry => entry != null)
 896                    .Select(ToStoredContextSource)
 897                    .ToList() ?? new List<StoredContextSource>(),
 898                LeastValuable = justification.ContextSources?.LeastValuable?
 899                    .Where(entry => entry != null)
 900                    .Select(ToStoredContextSource)
 901                    .ToList() ?? new List<StoredContextSource>()
 902            },
 903            Uncertainties = justification.Uncertainties?
 904                .Where(item => !string.IsNullOrWhiteSpace(item))
 905                .Select(item => item.Trim())
 906                .ToList() ?? new List<string>()
 907        };
 908
 909        return JsonSerializer.Serialize(stored, JustificationSerializerOptions);
 910    }
 911
 912    private static bool HasJustificationContent(PredictionJustification justification)
 913    {
 914        if (!string.IsNullOrWhiteSpace(justification.KeyReasoning))
 915        {
 916            return true;
 917        }
 918
 919        if (justification.ContextSources?.MostValuable != null &&
 920            justification.ContextSources.MostValuable.Any(HasSourceContent))
 921        {
 922            return true;
 923        }
 924
 925        if (justification.ContextSources?.LeastValuable != null &&
 926            justification.ContextSources.LeastValuable.Any(HasSourceContent))
 927        {
 928            return true;
 929        }
 930
 931        return justification.Uncertainties != null &&
 932               justification.Uncertainties.Any(item => !string.IsNullOrWhiteSpace(item));
 933    }
 934
 935    private static bool HasSourceContent(PredictionJustificationContextSource source)
 936    {
 937        return !string.IsNullOrWhiteSpace(source?.DocumentName) ||
 938               !string.IsNullOrWhiteSpace(source?.Details);
 939    }
 940
 941    private PredictionJustification? DeserializeJustification(string? serialized)
 942    {
 943        if (string.IsNullOrWhiteSpace(serialized))
 944        {
 945            return null;
 946        }
 947
 948        var trimmed = serialized.Trim();
 949
 950        if (!trimmed.StartsWith("{"))
 951        {
 952            return new PredictionJustification(
 953                trimmed,
 954                new PredictionJustificationContextSources(
 955                    Array.Empty<PredictionJustificationContextSource>(),
 956                    Array.Empty<PredictionJustificationContextSource>()),
 957                Array.Empty<string>());
 958        }
 959
 960        try
 961        {
 962            var stored = JsonSerializer.Deserialize<StoredJustification>(trimmed, JustificationSerializerOptions);
 963
 964            if (stored == null)
 965            {
 966                return null;
 967            }
 968
 969            var contextSources = stored.ContextSources ?? new StoredContextSources();
 970
 971            var mostValuable = contextSources.MostValuable?
 972                .Where(entry => entry != null)
 973                .Select(ToDomainContextSource)
 974                .ToList() ?? new List<PredictionJustificationContextSource>();
 975
 976            var leastValuable = contextSources.LeastValuable?
 977                .Where(entry => entry != null)
 978                .Select(ToDomainContextSource)
 979                .ToList() ?? new List<PredictionJustificationContextSource>();
 980
 981            var uncertainties = stored.Uncertainties?
 982                .Where(item => !string.IsNullOrWhiteSpace(item))
 983                .Select(item => item.Trim())
 984                .ToList() ?? new List<string>();
 985
 986            var justification = new PredictionJustification(
 987                stored.KeyReasoning?.Trim() ?? string.Empty,
 988                new PredictionJustificationContextSources(mostValuable, leastValuable),
 989                uncertainties);
 990
 991            return HasJustificationContent(justification) ? justification : null;
 992        }
 993        catch (JsonException ex)
 994        {
 995            _logger.LogWarning(ex, "Failed to parse structured justification JSON; falling back to legacy text format");
 996
 997            var fallbackJustification = new PredictionJustification(
 998                trimmed,
 999                new PredictionJustificationContextSources(
 1000                    Array.Empty<PredictionJustificationContextSource>(),
 1001                    Array.Empty<PredictionJustificationContextSource>()),
 1002                Array.Empty<string>());
 1003
 1004            return HasJustificationContent(fallbackJustification) ? fallbackJustification : null;
 1005        }
 1006    }
 1007
 1008    private static StoredContextSource ToStoredContextSource(PredictionJustificationContextSource source)
 1009    {
 1010        return new StoredContextSource
 1011        {
 1012            DocumentName = source.DocumentName?.Trim() ?? string.Empty,
 1013            Details = source.Details?.Trim() ?? string.Empty
 1014        };
 1015    }
 1016
 1017    private static PredictionJustificationContextSource ToDomainContextSource(StoredContextSource source)
 1018    {
 1019        var documentName = source.DocumentName?.Trim() ?? string.Empty;
 1020        var details = source.Details?.Trim() ?? string.Empty;
 1021        return new PredictionJustificationContextSource(documentName, details);
 1022    }
 1023
 1024    private sealed class StoredJustification
 1025    {
 1026        public string? KeyReasoning { get; set; }
 1027        public StoredContextSources? ContextSources { get; set; }
 1028        public List<string>? Uncertainties { get; set; }
 1029    }
 1030
 1031    private sealed class StoredContextSources
 1032    {
 1033        public List<StoredContextSource>? MostValuable { get; set; }
 1034        public List<StoredContextSource>? LeastValuable { get; set; }
 1035    }
 1036
 1037    private sealed class StoredContextSource
 1038    {
 11039        public string? DocumentName { get; set; }
 11040        public string? Details { get; set; }
 1041    }
 1042}