< Summary

Information
Class: FirebaseAdapter.FirebasePredictionRepository
Assembly: FirebaseAdapter
File(s): /home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebasePredictionRepository.cs
Line coverage
82%
Covered lines: 634
Uncovered lines: 130
Coverable lines: 764
Total lines: 1373
Line coverage: 82.9%
Branch coverage
79%
Covered branches: 222
Total branches: 280
Branch coverage: 79.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%44100%
SavePredictionAsync()100%8893.1%
GetPredictionAsync()100%11100%
GetPredictionAsync()100%2281.82%
GetPredictionMetadataAsync()100%1180%
GetMatchDayAsync()100%4475%
GetStoredMatchAsync()56.67%443075%
GetMatchDayWithPredictionsAsync()100%2272.73%
GetAllPredictionsAsync()100%2276.92%
HasPredictionAsync()100%1171.43%
SaveBonusPredictionAsync()100%121292.73%
GetBonusPredictionAsync()100%111080%
GetBonusPredictionByTextAsync()100%2285%
GetBonusPredictionMetadataByTextAsync()50%6677.27%
GetAllBonusPredictionsAsync()100%2281.25%
HasBonusPredictionAsync()100%1172.73%
StoreMatchAsync()100%2281.82%
ConvertToTimestamp(...)100%11100%
ConvertFromTimestamp(...)100%11100%
GetMatchRepredictionIndexAsync()100%2278.95%
GetCancelledMatchPredictionAsync()100%2283.33%
GetCancelledMatchPredictionMetadataAsync()66.67%6685.19%
GetCancelledMatchRepredictionIndexAsync()100%2280.95%
GetBonusRepredictionIndexAsync()100%2276.47%
SaveRepredictionAsync()100%1188.24%
SaveBonusRepredictionAsync()100%4487.88%
GetMatchPredictionCostsByRepredictionIndexAsync()100%111081.82%
GetBonusPredictionCostsByRepredictionIndexAsync()100%6680%
GetAvailableMatchdaysAsync()100%9875%
GetAvailableModelsAsync()100%151483.33%
GetAvailableCommunityContextsAsync()100%151483.33%
SerializeJustification(...)69.44%3636100%
HasJustificationContent(...)81.82%2222100%
HasSourceContent(...)33.33%66100%
DeserializeJustification(...)62.5%1694056.76%
ToStoredContextSource(...)100%88100%
ToDomainContextSource(...)50%88100%
get_KeyReasoning()100%11100%
set_KeyReasoning(...)100%11100%
get_ContextSources()100%11100%
set_ContextSources(...)100%11100%
get_Uncertainties()100%11100%
set_Uncertainties(...)100%11100%
get_MostValuable()100%11100%
set_MostValuable(...)100%11100%
get_LeastValuable()100%11100%
set_LeastValuable(...)100%11100%
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{
 118    private static readonly JsonSerializerOptions JustificationSerializerOptions = new()
 119    {
 120        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 121        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 122    };
 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
 131    public FirebasePredictionRepository(FirestoreDb firestoreDb, ILogger<FirebasePredictionRepository> logger)
 32    {
 133        _firestoreDb = firestoreDb ?? throw new ArgumentNullException(nameof(firestoreDb));
 134        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 35
 36        // Use unified collection names (no longer community-specific)
 137        _predictionsCollection = "match-predictions";
 138        _matchesCollection = "matches";
 139        _bonusPredictionsCollection = "bonus-predictions";
 140        _competition = "bundesliga-2025-26"; // Remove community suffix
 41
 142        _logger.LogInformation("Firebase repository initialized");
 143    }
 44
 45    public async Task SavePredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double co
 46    {
 47        try
 48        {
 149            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
 153            var query = _firestoreDb.Collection(_predictionsCollection)
 154                .WhereEqualTo("homeTeam", match.HomeTeam)
 155                .WhereEqualTo("awayTeam", match.AwayTeam)
 156                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 157                .WhereEqualTo("competition", _competition)
 158                .WhereEqualTo("model", model)
 159                .WhereEqualTo("communityContext", communityContext)
 160                .OrderByDescending("repredictionIndex")
 161                .Limit(1);
 62
 163            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 64
 65            DocumentReference docRef;
 166            bool isUpdate = false;
 167            Timestamp? existingCreatedAt = null;
 168            int repredictionIndex = 0;
 69
 170            if (snapshot.Documents.Count > 0)
 71            {
 72                // Update existing document (latest reprediction)
 173                var existingDoc = snapshot.Documents.First();
 174                docRef = existingDoc.Reference;
 175                isUpdate = true;
 76
 77                // Preserve the original values
 178                var existingData = existingDoc.ConvertTo<FirestoreMatchPrediction>();
 179                existingCreatedAt = existingData.CreatedAt;
 180                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 81
 182                _logger.LogDebug("Updating existing prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId
 183                    match.HomeTeam, match.AwayTeam, existingDoc.Id, repredictionIndex);
 84            }
 85            else
 86            {
 87                // Create new document
 188                var documentId = Guid.NewGuid().ToString();
 189                docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 190                repredictionIndex = 0; // First prediction
 91
 192                _logger.LogDebug("Creating new prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, re
 193                    match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 94            }
 95
 196            var firestorePrediction = new FirestoreMatchPrediction
 197            {
 198                Id = docRef.Id,
 199                HomeTeam = match.HomeTeam,
 1100                AwayTeam = match.AwayTeam,
 1101                StartsAt = ConvertToTimestamp(match.StartsAt),
 1102                Matchday = match.Matchday,
 1103                HomeGoals = prediction.HomeGoals,
 1104                AwayGoals = prediction.AwayGoals,
 1105                Justification = SerializeJustification(prediction.Justification),
 1106                UpdatedAt = now,
 1107                Competition = _competition,
 1108                Model = model,
 1109                TokenUsage = tokenUsage,
 1110                Cost = cost,
 1111                CommunityContext = communityContext,
 1112                ContextDocumentNames = contextDocumentNames.ToArray(),
 1113                RepredictionIndex = repredictionIndex
 1114            };
 115
 116            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 1117            firestorePrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreatedAt.V
 118
 1119            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 120
 1121            var action = isUpdate ? "Updated" : "Saved";
 1122            _logger.LogInformation("{Action} prediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repre
 1123                action, match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 1124        }
 0125        catch (Exception ex)
 126        {
 0127            _logger.LogError(ex, "Failed to save prediction for match {HomeTeam} vs {AwayTeam}",
 0128                match.HomeTeam, match.AwayTeam);
 0129            throw;
 130        }
 1131    }
 132
 133    public async Task<Prediction?> GetPredictionAsync(Match match, string model, string communityContext, CancellationTo
 134    {
 1135        return await GetPredictionAsync(match.HomeTeam, match.AwayTeam, match.StartsAt, model, communityContext, cancell
 1136    }
 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
 1144            var query = _firestoreDb.Collection(_predictionsCollection)
 1145                .WhereEqualTo("homeTeam", homeTeam)
 1146                .WhereEqualTo("awayTeam", awayTeam)
 1147                .WhereEqualTo("startsAt", ConvertToTimestamp(startsAt))
 1148                .WhereEqualTo("competition", _competition)
 1149                .WhereEqualTo("model", model)
 1150                .WhereEqualTo("communityContext", communityContext)
 1151                .OrderByDescending("repredictionIndex")
 1152                .Limit(1);
 153
 1154            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 155
 1156            if (snapshot.Documents.Count == 0)
 157            {
 1158                return null;
 159            }
 160
 1161            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1162            return new Prediction(
 1163                firestorePrediction.HomeGoals,
 1164                firestorePrediction.AwayGoals,
 1165                DeserializeJustification(firestorePrediction.Justification));
 166        }
 0167        catch (Exception ex)
 168        {
 0169            _logger.LogError(ex, "Failed to get prediction for match {HomeTeam} vs {AwayTeam} using model {Model} and co
 0170                homeTeam, awayTeam, model, communityContext);
 0171            throw;
 172        }
 1173    }
 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.
 1181            var query = _firestoreDb.Collection(_predictionsCollection)
 1182                .WhereEqualTo("homeTeam", match.HomeTeam)
 1183                .WhereEqualTo("awayTeam", match.AwayTeam)
 1184                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1185                .WhereEqualTo("competition", _competition)
 1186                .WhereEqualTo("model", model)
 1187                .WhereEqualTo("communityContext", communityContext)
 1188                .OrderByDescending("repredictionIndex")
 1189                .Limit(1);
 190
 1191            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 192
 1193            if (snapshot.Documents.Count == 0)
 194            {
 0195                return null;
 196            }
 197
 1198            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1199            var prediction = new Prediction(
 1200                firestorePrediction.HomeGoals,
 1201                firestorePrediction.AwayGoals,
 1202                DeserializeJustification(firestorePrediction.Justification));
 1203            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 1204            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 205
 1206            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 207        }
 0208        catch (Exception ex)
 209        {
 0210            _logger.LogError(ex, "Failed to get prediction metadata for match {HomeTeam} vs {AwayTeam} using model {Mode
 0211                match.HomeTeam, match.AwayTeam, model, communityContext);
 0212            throw;
 213        }
 1214    }
 215
 216    public async Task<IReadOnlyList<Match>> GetMatchDayAsync(int matchDay, CancellationToken cancellationToken = default
 217    {
 218        try
 219        {
 1220            var query = _firestoreDb.Collection(_matchesCollection)
 1221                .WhereEqualTo("competition", _competition)
 1222                .WhereEqualTo("matchday", matchDay)
 1223                .OrderBy("startsAt");
 224
 1225            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 226
 1227            var matches = snapshot.Documents
 1228                .Select(doc => doc.ConvertTo<FirestoreMatch>())
 1229                .Select(fm => new Match(
 1230                    fm.HomeTeam,
 1231                    fm.AwayTeam,
 1232                    ConvertFromTimestamp(fm.StartsAt),
 1233                    fm.Matchday,
 1234                    fm.IsCancelled))
 1235                .ToList();
 236
 1237            return matches.AsReadOnly();
 238        }
 0239        catch (Exception ex)
 240        {
 0241            _logger.LogError(ex, "Failed to get matches for matchday {Matchday}", matchDay);
 0242            throw;
 243        }
 1244    }
 245
 246    public async Task<Match?> GetStoredMatchAsync(string homeTeam, string awayTeam, int matchDay, string? model = null, 
 247    {
 248        try
 249        {
 1250            var matchQuery = _firestoreDb.Collection(_matchesCollection)
 1251                .WhereEqualTo("competition", _competition)
 1252                .WhereEqualTo("matchday", matchDay)
 1253                .WhereEqualTo("homeTeam", homeTeam)
 1254                .WhereEqualTo("awayTeam", awayTeam);
 255
 1256            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 257
 1258            if (matchSnapshot.Documents.Count > 0)
 259            {
 0260                if (matchSnapshot.Documents.Count > 1)
 261                {
 0262                    _logger.LogWarning("Found {Count} stored match documents for {HomeTeam} vs {AwayTeam} on matchday {M
 263                }
 264
 0265                return matchSnapshot.Documents
 0266                    .Select(document => document.ConvertTo<FirestoreMatch>())
 0267                    .Select(firestoreMatch => new Match(
 0268                        firestoreMatch.HomeTeam,
 0269                        firestoreMatch.AwayTeam,
 0270                        ConvertFromTimestamp(firestoreMatch.StartsAt),
 0271                        firestoreMatch.Matchday,
 0272                        firestoreMatch.IsCancelled))
 0273                    .OrderBy(match => match.StartsAt.ToInstant())
 0274                    .ThenBy(match => match.IsCancelled)
 0275                    .First();
 276            }
 277
 1278            Query predictionQuery = _firestoreDb.Collection(_predictionsCollection)
 1279                .WhereEqualTo("competition", _competition)
 1280                .WhereEqualTo("matchday", matchDay)
 1281                .WhereEqualTo("homeTeam", homeTeam)
 1282                .WhereEqualTo("awayTeam", awayTeam);
 283
 1284            if (!string.IsNullOrWhiteSpace(model))
 285            {
 1286                predictionQuery = predictionQuery.WhereEqualTo("model", model);
 287            }
 288
 1289            if (!string.IsNullOrWhiteSpace(communityContext))
 290            {
 1291                predictionQuery = predictionQuery.WhereEqualTo("communityContext", communityContext);
 292            }
 293
 1294            var predictionSnapshot = await predictionQuery.GetSnapshotAsync(cancellationToken);
 295
 1296            if (predictionSnapshot.Documents.Count == 0)
 297            {
 0298                return null;
 299            }
 300
 1301            if (predictionSnapshot.Documents.Count > 1)
 302            {
 0303                _logger.LogWarning("Found {Count} stored prediction documents for {HomeTeam} vs {AwayTeam} on matchday {
 304            }
 305
 1306            var firestorePrediction = predictionSnapshot.Documents
 1307                .Select(document => document.ConvertTo<FirestoreMatchPrediction>())
 1308                .OrderByDescending(prediction => prediction.RepredictionIndex)
 1309                .ThenByDescending(prediction => prediction.CreatedAt.ToDateTimeOffset())
 1310                .ThenBy(prediction => prediction.StartsAt.ToDateTimeOffset())
 1311                .ThenBy(prediction => prediction.Id, StringComparer.Ordinal)
 1312                .First();
 313
 1314            return new Match(
 1315                firestorePrediction.HomeTeam,
 1316                firestorePrediction.AwayTeam,
 1317                ConvertFromTimestamp(firestorePrediction.StartsAt),
 1318                firestorePrediction.Matchday);
 319        }
 0320        catch (Exception ex)
 321        {
 0322            _logger.LogError(ex, "Failed to get stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}", homeTeam
 0323            throw;
 324        }
 1325    }
 326
 327    public async Task<IReadOnlyList<MatchPrediction>> GetMatchDayWithPredictionsAsync(int matchDay, string model, string
 328    {
 329        try
 330        {
 331            // Get all matches for the matchday
 1332            var matches = await GetMatchDayAsync(matchDay, cancellationToken);
 333
 334            // Get predictions for all matches using the specified model and community context
 1335            var matchPredictions = new List<MatchPrediction>();
 336
 1337            foreach (var match in matches)
 338            {
 1339                var prediction = await GetPredictionAsync(match, model, communityContext, cancellationToken);
 1340                matchPredictions.Add(new MatchPrediction(match, prediction));
 1341            }
 342
 1343            return matchPredictions.AsReadOnly();
 344        }
 0345        catch (Exception ex)
 346        {
 0347            _logger.LogError(ex, "Failed to get matches with predictions for matchday {Matchday} using model {Model} and
 0348            throw;
 349        }
 1350    }
 351
 352    public async Task<IReadOnlyList<MatchPrediction>> GetAllPredictionsAsync(string model, string communityContext, Canc
 353    {
 354        try
 355        {
 1356            var query = _firestoreDb.Collection(_predictionsCollection)
 1357                .WhereEqualTo("competition", _competition)
 1358                .WhereEqualTo("model", model)
 1359                .WhereEqualTo("communityContext", communityContext)
 1360                .OrderBy("matchday");
 361
 1362            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 363
 1364            var matchPredictions = snapshot.Documents
 1365                .Select(doc => doc.ConvertTo<FirestoreMatchPrediction>())
 1366                .Select(fp => new MatchPrediction(
 1367                    new Match(fp.HomeTeam, fp.AwayTeam, ConvertFromTimestamp(fp.StartsAt), fp.Matchday),
 1368                    new Prediction(
 1369                        fp.HomeGoals,
 1370                        fp.AwayGoals,
 1371                        DeserializeJustification(fp.Justification))))
 1372                .ToList();
 373
 1374            return matchPredictions.AsReadOnly();
 375        }
 0376        catch (Exception ex)
 377        {
 0378            _logger.LogError(ex, "Failed to get all predictions for model {Model} and community context {CommunityContex
 0379            throw;
 380        }
 1381    }
 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
 1388            var query = _firestoreDb.Collection(_predictionsCollection)
 1389                .WhereEqualTo("homeTeam", match.HomeTeam)
 1390                .WhereEqualTo("awayTeam", match.AwayTeam)
 1391                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1392                .WhereEqualTo("competition", _competition)
 1393                .WhereEqualTo("model", model)
 1394                .WhereEqualTo("communityContext", communityContext);
 395
 1396            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1397            return snapshot.Documents.Count > 0;
 398        }
 0399        catch (Exception ex)
 400        {
 0401            _logger.LogError(ex, "Failed to check if prediction exists for match {HomeTeam} vs {AwayTeam} using model {M
 0402                match.HomeTeam, match.AwayTeam, model, communityContext);
 0403            throw;
 404        }
 1405    }
 406
 407    public async Task SaveBonusPredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mode
 408    {
 409        try
 410        {
 1411            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
 1415            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1416                .WhereEqualTo("questionText", bonusQuestion.Text)
 1417                .WhereEqualTo("competition", _competition)
 1418                .WhereEqualTo("model", model)
 1419                .WhereEqualTo("communityContext", communityContext)
 1420                .OrderByDescending("repredictionIndex")
 1421                .Limit(1);
 422
 1423            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 424
 425            DocumentReference docRef;
 1426            bool isUpdate = false;
 1427            Timestamp? existingCreatedAt = null;
 1428            int repredictionIndex = 0;
 429
 1430            if (snapshot.Documents.Count > 0)
 431            {
 432                // Update existing document (latest reprediction)
 1433                var existingDoc = snapshot.Documents.First();
 1434                docRef = existingDoc.Reference;
 1435                isUpdate = true;
 436
 437                // Preserve the original values
 1438                var existingData = existingDoc.ConvertTo<FirestoreBonusPrediction>();
 1439                existingCreatedAt = existingData.CreatedAt;
 1440                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 441
 1442                _logger.LogDebug("Updating existing bonus prediction for question '{QuestionText}' (document: {DocumentI
 1443                    bonusQuestion.Text, existingDoc.Id, repredictionIndex);
 444            }
 445            else
 446            {
 447                // Create new document
 1448                var documentId = Guid.NewGuid().ToString();
 1449                docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 1450                repredictionIndex = 0; // First prediction
 451
 1452                _logger.LogDebug("Creating new bonus prediction for question '{QuestionText}' (document: {DocumentId}, r
 1453                    bonusQuestion.Text, documentId, repredictionIndex);
 454            }
 455
 456            // Extract selected option texts for observability
 1457            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 1458            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 1459                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 1460                .ToArray();
 461
 1462            var firestoreBonusPrediction = new FirestoreBonusPrediction
 1463            {
 1464                Id = docRef.Id,
 1465                QuestionText = bonusQuestion.Text,
 1466                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 1467                SelectedOptionTexts = selectedOptionTexts,
 1468                UpdatedAt = now,
 1469                Competition = _competition,
 1470                Model = model,
 1471                TokenUsage = tokenUsage,
 1472                Cost = cost,
 1473                CommunityContext = communityContext,
 1474                ContextDocumentNames = contextDocumentNames.ToArray(),
 1475                RepredictionIndex = repredictionIndex
 1476            };
 477
 478            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 1479            firestoreBonusPrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreate
 480
 1481            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 482
 1483            var action = isUpdate ? "Updated" : "Saved";
 1484            _logger.LogDebug("{Action} bonus prediction for question '{QuestionText}' with selections: {SelectedOptions}
 1485                action, bonusQuestion.Text, string.Join(", ", selectedOptionTexts), repredictionIndex);
 1486        }
 0487        catch (Exception ex)
 488        {
 0489            _logger.LogError(ex, "Failed to save bonus prediction for question: {QuestionText}",
 0490                bonusQuestion.Text);
 0491            throw;
 492        }
 1493    }
 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
 1500            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1501                .WhereEqualTo("questionId", questionId)
 1502                .WhereEqualTo("competition", _competition)
 1503                .WhereEqualTo("model", model)
 1504                .WhereEqualTo("communityContext", communityContext);
 505
 1506            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 507
 1508            if (snapshot.Documents.Count == 0)
 509            {
 1510                return null;
 511            }
 512
 1513            var firestoreBonusPrediction = snapshot.Documents
 1514                .Select(document => document.ConvertTo<FirestoreBonusPrediction>())
 1515                .OrderByDescending(prediction => prediction.RepredictionIndex)
 1516                .ThenByDescending(prediction => prediction.CreatedAt.ToDateTimeOffset())
 1517                .ThenBy(prediction => prediction.Id, StringComparer.Ordinal)
 1518                .First();
 519
 1520            return new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 521        }
 0522        catch (Exception ex)
 523        {
 0524            _logger.LogError(ex, "Failed to get bonus prediction for question {QuestionId} using model {Model} and commu
 0525            throw;
 526        }
 1527    }
 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
 1535            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1536                .WhereEqualTo("questionText", questionText)
 1537                .WhereEqualTo("competition", _competition)
 1538                .WhereEqualTo("model", model)
 1539                .WhereEqualTo("communityContext", communityContext)
 1540                .OrderByDescending("repredictionIndex")
 1541                .Limit(1);
 542
 1543            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 544
 1545            if (snapshot.Documents.Count == 0)
 546            {
 1547                _logger.LogDebug("No bonus prediction found for question text: {QuestionText} with model: {Model} and co
 1548                return null;
 549            }
 550
 1551            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 1552            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 553
 1554            _logger.LogDebug("Found bonus prediction for question text: {QuestionText} with model: {Model} and community
 1555                questionText, model, communityContext, firestoreBonusPrediction.RepredictionIndex);
 556
 1557            return bonusPrediction;
 558        }
 0559        catch (Exception ex)
 560        {
 0561            _logger.LogError(ex, "Failed to retrieve bonus prediction by text: {QuestionText} with model: {Model} and co
 0562            throw;
 563        }
 1564    }
 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.
 1572            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1573                .WhereEqualTo("questionText", questionText)
 1574                .WhereEqualTo("competition", _competition)
 1575                .WhereEqualTo("model", model)
 1576                .WhereEqualTo("communityContext", communityContext)
 1577                .OrderByDescending("repredictionIndex")
 1578                .Limit(1);
 579
 1580            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 581
 1582            if (snapshot.Documents.Count == 0)
 583            {
 0584                _logger.LogDebug("No bonus prediction metadata found for question text: {QuestionText} with model: {Mode
 0585                return null;
 586            }
 587
 1588            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 1589            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 1590            var createdAt = firestoreBonusPrediction.CreatedAt.ToDateTimeOffset();
 1591            var contextDocumentNames = firestoreBonusPrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 592
 1593            _logger.LogDebug("Found bonus prediction metadata for question text: {QuestionText} with model: {Model} and 
 1594                questionText, model, communityContext);
 595
 1596            return new BonusPredictionMetadata(bonusPrediction, createdAt, contextDocumentNames);
 597        }
 0598        catch (Exception ex)
 599        {
 0600            _logger.LogError(ex, "Failed to retrieve bonus prediction metadata by text: {QuestionText} with model: {Mode
 0601            throw;
 602        }
 1603    }
 604
 605    public async Task<IReadOnlyList<BonusPrediction>> GetAllBonusPredictionsAsync(string model, string communityContext,
 606    {
 607        try
 608        {
 1609            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1610                .WhereEqualTo("competition", _competition)
 1611                .WhereEqualTo("model", model)
 1612                .WhereEqualTo("communityContext", communityContext)
 1613                .OrderBy("createdAt");
 614
 1615            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 616
 1617            var bonusPredictions = new List<BonusPrediction>();
 1618            foreach (var document in snapshot.Documents)
 619            {
 1620                var firestoreBonusPrediction = document.ConvertTo<FirestoreBonusPrediction>();
 1621                bonusPredictions.Add(new BonusPrediction(
 1622                    firestoreBonusPrediction.SelectedOptionIds.ToList()));
 623            }
 624
 1625            return bonusPredictions.AsReadOnly();
 626        }
 0627        catch (Exception ex)
 628        {
 0629            _logger.LogError(ex, "Failed to get all bonus predictions for model {Model} and community context {Community
 0630            throw;
 631        }
 1632    }
 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
 1639            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1640                .WhereEqualTo("questionId", questionId)
 1641                .WhereEqualTo("competition", _competition)
 1642                .WhereEqualTo("model", model)
 1643                .WhereEqualTo("communityContext", communityContext);
 644
 1645            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1646            return snapshot.Documents.Count > 0;
 647        }
 0648        catch (Exception ex)
 649        {
 0650            _logger.LogError(ex, "Failed to check if bonus prediction exists for question {QuestionId} using model {Mode
 0651            throw;
 652        }
 1653    }
 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        {
 1663            var documentId = Guid.NewGuid().ToString();
 664
 1665            var firestoreMatch = new FirestoreMatch
 1666            {
 1667                Id = documentId,
 1668                HomeTeam = match.HomeTeam,
 1669                AwayTeam = match.AwayTeam,
 1670                StartsAt = ConvertToTimestamp(match.StartsAt),
 1671                Matchday = match.Matchday,
 1672                Competition = _competition,
 1673                IsCancelled = match.IsCancelled
 1674            };
 675
 1676            await _firestoreDb.Collection(_matchesCollection)
 1677                .Document(documentId)
 1678                .SetAsync(firestoreMatch, cancellationToken: cancellationToken);
 679
 1680            _logger.LogDebug("Stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}{Cancelled}",
 1681                match.HomeTeam, match.AwayTeam, match.Matchday, match.IsCancelled ? " (CANCELLED)" : "");
 1682        }
 0683        catch (Exception ex)
 684        {
 0685            _logger.LogError(ex, "Failed to store match {HomeTeam} vs {AwayTeam}",
 0686                match.HomeTeam, match.AwayTeam);
 0687            throw;
 688        }
 1689    }
 690
 691    private static Timestamp ConvertToTimestamp(ZonedDateTime zonedDateTime)
 692    {
 1693        var instant = zonedDateTime.ToInstant();
 1694        return Timestamp.FromDateTimeOffset(instant.ToDateTimeOffset());
 695    }
 696
 697    private static ZonedDateTime ConvertFromTimestamp(Timestamp timestamp)
 698    {
 1699        var dateTimeOffset = timestamp.ToDateTimeOffset();
 1700        var instant = Instant.FromDateTimeOffset(dateTimeOffset);
 1701        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
 1710            var query = _firestoreDb.Collection(_predictionsCollection)
 1711                .WhereEqualTo("homeTeam", match.HomeTeam)
 1712                .WhereEqualTo("awayTeam", match.AwayTeam)
 1713                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1714                .WhereEqualTo("competition", _competition)
 1715                .WhereEqualTo("model", model)
 1716                .WhereEqualTo("communityContext", communityContext)
 1717                .OrderByDescending("repredictionIndex")
 1718                .Limit(1);
 719
 1720            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 721
 1722            if (snapshot.Documents.Count == 0)
 723            {
 1724                return -1; // No prediction exists
 725            }
 726
 1727            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1728            return firestorePrediction.RepredictionIndex;
 729        }
 0730        catch (Exception ex)
 731        {
 0732            _logger.LogError(ex, "Failed to get reprediction index for match {HomeTeam} vs {AwayTeam} using model {Model
 0733                match.HomeTeam, match.AwayTeam, model, communityContext);
 0734            throw;
 735        }
 1736    }
 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
 1749            var query = _firestoreDb.Collection(_predictionsCollection)
 1750                .WhereEqualTo("homeTeam", homeTeam)
 1751                .WhereEqualTo("awayTeam", awayTeam)
 1752                .WhereEqualTo("competition", _competition)
 1753                .WhereEqualTo("model", model)
 1754                .WhereEqualTo("communityContext", communityContext)
 1755                .OrderByDescending("createdAt")
 1756                .Limit(1);
 757
 1758            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 759
 1760            if (snapshot.Documents.Count == 0)
 761            {
 1762                _logger.LogDebug("No prediction found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-only look
 1763                return null;
 764            }
 765
 1766            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1767            _logger.LogDebug("Found prediction for cancelled match {HomeTeam} vs {AwayTeam} with startsAt={StartsAt} (te
 1768                homeTeam, awayTeam, firestorePrediction.StartsAt);
 769
 1770            return new Prediction(
 1771                firestorePrediction.HomeGoals,
 1772                firestorePrediction.AwayGoals,
 1773                DeserializeJustification(firestorePrediction.Justification));
 774        }
 0775        catch (Exception ex)
 776        {
 0777            _logger.LogError(ex, "Failed to get prediction for cancelled match {HomeTeam} vs {AwayTeam} using model {Mod
 0778                homeTeam, awayTeam, model, communityContext);
 0779            throw;
 780        }
 1781    }
 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
 1789            var query = _firestoreDb.Collection(_predictionsCollection)
 1790                .WhereEqualTo("homeTeam", homeTeam)
 1791                .WhereEqualTo("awayTeam", awayTeam)
 1792                .WhereEqualTo("competition", _competition)
 1793                .WhereEqualTo("model", model)
 1794                .WhereEqualTo("communityContext", communityContext)
 1795                .OrderByDescending("repredictionIndex")
 1796                .Limit(1);
 797
 1798            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 799
 1800            if (snapshot.Documents.Count == 0)
 801            {
 1802                _logger.LogDebug("No prediction metadata found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-
 1803                return null;
 804            }
 805
 1806            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1807            _logger.LogDebug("Found prediction metadata for cancelled match {HomeTeam} vs {AwayTeam} with startsAt={Star
 1808                homeTeam, awayTeam, firestorePrediction.StartsAt);
 809
 1810            var prediction = new Prediction(
 1811                firestorePrediction.HomeGoals,
 1812                firestorePrediction.AwayGoals,
 1813                DeserializeJustification(firestorePrediction.Justification));
 1814            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 1815            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 816
 1817            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 818        }
 0819        catch (Exception ex)
 820        {
 0821            _logger.LogError(ex, "Failed to get prediction metadata for cancelled match {HomeTeam} vs {AwayTeam} using m
 0822                homeTeam, awayTeam, model, communityContext);
 0823            throw;
 824        }
 1825    }
 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
 1833            var query = _firestoreDb.Collection(_predictionsCollection)
 1834                .WhereEqualTo("homeTeam", homeTeam)
 1835                .WhereEqualTo("awayTeam", awayTeam)
 1836                .WhereEqualTo("competition", _competition)
 1837                .WhereEqualTo("model", model)
 1838                .WhereEqualTo("communityContext", communityContext)
 1839                .OrderByDescending("repredictionIndex")
 1840                .Limit(1);
 841
 1842            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 843
 1844            if (snapshot.Documents.Count == 0)
 845            {
 1846                _logger.LogDebug("No reprediction index found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-o
 1847                return -1;
 848            }
 849
 1850            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1851            _logger.LogDebug("Found reprediction index {Index} for cancelled match {HomeTeam} vs {AwayTeam} with startsA
 1852                firestorePrediction.RepredictionIndex, homeTeam, awayTeam, firestorePrediction.StartsAt);
 853
 1854            return firestorePrediction.RepredictionIndex;
 855        }
 0856        catch (Exception ex)
 857        {
 0858            _logger.LogError(ex, "Failed to get reprediction index for cancelled match {HomeTeam} vs {AwayTeam} using mo
 0859                homeTeam, awayTeam, model, communityContext);
 0860            throw;
 861        }
 1862    }
 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
 1870            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1871                .WhereEqualTo("questionText", questionText)
 1872                .WhereEqualTo("competition", _competition)
 1873                .WhereEqualTo("model", model)
 1874                .WhereEqualTo("communityContext", communityContext)
 1875                .OrderByDescending("repredictionIndex")
 1876                .Limit(1);
 877
 1878            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 879
 1880            if (snapshot.Documents.Count == 0)
 881            {
 1882                return -1; // No prediction exists
 883            }
 884
 1885            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 1886            return firestorePrediction.RepredictionIndex;
 887        }
 0888        catch (Exception ex)
 889        {
 0890            _logger.LogError(ex, "Failed to get reprediction index for bonus question '{QuestionText}' using model {Mode
 0891                questionText, model, communityContext);
 0892            throw;
 893        }
 1894    }
 895
 896    public async Task SaveRepredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double 
 897    {
 898        try
 899        {
 1900            var now = Timestamp.GetCurrentTimestamp();
 901
 902            // Create new document for this reprediction
 1903            var documentId = Guid.NewGuid().ToString();
 1904            var docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 905
 1906            _logger.LogDebug("Creating reprediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, repredic
 1907                match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 908
 1909            var firestorePrediction = new FirestoreMatchPrediction
 1910            {
 1911                Id = docRef.Id,
 1912                HomeTeam = match.HomeTeam,
 1913                AwayTeam = match.AwayTeam,
 1914                StartsAt = ConvertToTimestamp(match.StartsAt),
 1915                Matchday = match.Matchday,
 1916                HomeGoals = prediction.HomeGoals,
 1917                AwayGoals = prediction.AwayGoals,
 1918                Justification = SerializeJustification(prediction.Justification),
 1919                CreatedAt = now,
 1920                UpdatedAt = now,
 1921                Competition = _competition,
 1922                Model = model,
 1923                TokenUsage = tokenUsage,
 1924                Cost = cost,
 1925                CommunityContext = communityContext,
 1926                ContextDocumentNames = contextDocumentNames.ToArray(),
 1927                RepredictionIndex = repredictionIndex
 1928            };
 929
 1930            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 931
 1932            _logger.LogInformation("Saved reprediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repred
 1933                match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 1934        }
 0935        catch (Exception ex)
 936        {
 0937            _logger.LogError(ex, "Failed to save reprediction for match {HomeTeam} vs {AwayTeam}",
 0938                match.HomeTeam, match.AwayTeam);
 0939            throw;
 940        }
 1941    }
 942
 943    public async Task SaveBonusRepredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mo
 944    {
 945        try
 946        {
 1947            var now = Timestamp.GetCurrentTimestamp();
 948
 949            // Create new document for this reprediction
 1950            var documentId = Guid.NewGuid().ToString();
 1951            var docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 952
 1953            _logger.LogDebug("Creating bonus reprediction for question '{QuestionText}' (document: {DocumentId}, repredi
 1954                bonusQuestion.Text, documentId, repredictionIndex);
 955
 956            // Extract selected option texts for observability
 1957            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 1958            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 1959                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 1960                .ToArray();
 961
 1962            var firestoreBonusPrediction = new FirestoreBonusPrediction
 1963            {
 1964                Id = docRef.Id,
 1965                QuestionText = bonusQuestion.Text,
 1966                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 1967                SelectedOptionTexts = selectedOptionTexts,
 1968                CreatedAt = now,
 1969                UpdatedAt = now,
 1970                Competition = _competition,
 1971                Model = model,
 1972                TokenUsage = tokenUsage,
 1973                Cost = cost,
 1974                CommunityContext = communityContext,
 1975                ContextDocumentNames = contextDocumentNames.ToArray(),
 1976                RepredictionIndex = repredictionIndex
 1977            };
 978
 1979            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 980
 1981            _logger.LogInformation("Saved bonus reprediction for question '{QuestionText}' (reprediction index: {Repredi
 1982                bonusQuestion.Text, repredictionIndex);
 1983        }
 0984        catch (Exception ex)
 985        {
 0986            _logger.LogError(ex, "Failed to save bonus reprediction for question: {QuestionText}",
 0987                bonusQuestion.Text);
 0988            throw;
 989        }
 1990    }
 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        {
 11004            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 1005
 1006            // Query for match predictions with cost data
 11007            var query = _firestoreDb.Collection(_predictionsCollection)
 11008                .WhereEqualTo("competition", _competition)
 11009                .WhereEqualTo("model", model)
 11010                .WhereEqualTo("communityContext", communityContext);
 1011
 1012            // Add matchday filter if specified
 11013            if (matchdays?.Count > 0)
 1014            {
 11015                query = query.WhereIn("matchday", matchdays.Cast<object>().ToArray());
 1016            }
 1017
 11018            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1019
 11020            foreach (var doc in snapshot.Documents)
 1021            {
 11022                if (doc.Exists)
 1023                {
 11024                    var prediction = doc.ConvertTo<FirestoreMatchPrediction>();
 11025                    var repredictionIndex = prediction.RepredictionIndex;
 1026
 11027                    if (!costsByIndex.ContainsKey(repredictionIndex))
 1028                    {
 11029                        costsByIndex[repredictionIndex] = (0.0, 0);
 1030                    }
 1031
 11032                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 11033                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 1034                }
 1035            }
 1036
 11037            return costsByIndex;
 1038        }
 01039        catch (Exception ex)
 1040        {
 01041            _logger.LogError(ex, "Failed to get match prediction costs by reprediction index for model {Model} and commu
 01042                model, communityContext);
 01043            throw;
 1044        }
 11045    }
 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        {
 11058            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 1059
 1060            // Query for bonus predictions with cost data
 11061            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 11062                .WhereEqualTo("competition", _competition)
 11063                .WhereEqualTo("model", model)
 11064                .WhereEqualTo("communityContext", communityContext);
 1065
 11066            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1067
 11068            foreach (var doc in snapshot.Documents)
 1069            {
 11070                if (doc.Exists)
 1071                {
 11072                    var prediction = doc.ConvertTo<FirestoreBonusPrediction>();
 11073                    var repredictionIndex = prediction.RepredictionIndex;
 1074
 11075                    if (!costsByIndex.ContainsKey(repredictionIndex))
 1076                    {
 11077                        costsByIndex[repredictionIndex] = (0.0, 0);
 1078                    }
 1079
 11080                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 11081                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 1082                }
 1083            }
 1084
 11085            return costsByIndex;
 1086        }
 01087        catch (Exception ex)
 1088        {
 01089            _logger.LogError(ex, "Failed to get bonus prediction costs by reprediction index for model {Model} and commu
 01090                model, communityContext);
 01091            throw;
 1092        }
 11093    }
 1094
 1095    /// <inheritdoc />
 1096    public async Task<List<int>> GetAvailableMatchdaysAsync(CancellationToken cancellationToken = default)
 1097    {
 1098        try
 1099        {
 11100            var matchdays = new HashSet<int>();
 1101
 1102            // Query match predictions for unique matchdays
 11103            var query = _firestoreDb.Collection(_predictionsCollection)
 11104                .WhereEqualTo("competition", _competition);
 11105            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1106
 11107            foreach (var doc in snapshot.Documents)
 1108            {
 11109                if (doc.TryGetValue<int>("matchday", out var matchday) && matchday > 0)
 1110                {
 11111                    matchdays.Add(matchday);
 1112                }
 1113            }
 1114
 11115            return matchdays.OrderBy(m => m).ToList();
 1116        }
 01117        catch (Exception ex)
 1118        {
 01119            _logger.LogError(ex, "Failed to get available matchdays");
 01120            throw;
 1121        }
 11122    }
 1123
 1124    /// <inheritdoc />
 1125    public async Task<List<string>> GetAvailableModelsAsync(CancellationToken cancellationToken = default)
 1126    {
 1127        try
 1128        {
 11129            var models = new HashSet<string>();
 1130
 1131            // Query match predictions for unique models
 11132            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 11133                .WhereEqualTo("competition", _competition);
 11134            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1135
 11136            foreach (var doc in matchSnapshot.Documents)
 1137            {
 11138                if (doc.TryGetValue<string>("model", out var model) && !string.IsNullOrWhiteSpace(model))
 1139                {
 11140                    models.Add(model);
 1141                }
 1142            }
 1143
 1144            // Query bonus predictions for unique models
 11145            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 11146                .WhereEqualTo("competition", _competition);
 11147            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1148
 11149            foreach (var doc in bonusSnapshot.Documents)
 1150            {
 11151                if (doc.TryGetValue<string>("model", out var model) && !string.IsNullOrWhiteSpace(model))
 1152                {
 11153                    models.Add(model);
 1154                }
 1155            }
 1156
 11157            return models.OrderBy(model => model, StringComparer.Ordinal).ToList();
 1158        }
 01159        catch (Exception ex)
 1160        {
 01161            _logger.LogError(ex, "Failed to get available models");
 01162            throw;
 1163        }
 11164    }
 1165
 1166    /// <inheritdoc />
 1167    public async Task<List<string>> GetAvailableCommunityContextsAsync(CancellationToken cancellationToken = default)
 1168    {
 1169        try
 1170        {
 11171            var communityContexts = new HashSet<string>();
 1172
 1173            // Query match predictions for unique community contexts
 11174            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 11175                .WhereEqualTo("competition", _competition);
 11176            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1177
 11178            foreach (var doc in matchSnapshot.Documents)
 1179            {
 11180                if (doc.TryGetValue<string>("communityContext", out var context) && !string.IsNullOrWhiteSpace(context))
 1181                {
 11182                    communityContexts.Add(context);
 1183                }
 1184            }
 1185
 1186            // Query bonus predictions for unique community contexts
 11187            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 11188                .WhereEqualTo("competition", _competition);
 11189            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1190
 11191            foreach (var doc in bonusSnapshot.Documents)
 1192            {
 11193                if (doc.TryGetValue<string>("communityContext", out var context) && !string.IsNullOrWhiteSpace(context))
 1194                {
 11195                    communityContexts.Add(context);
 1196                }
 1197            }
 1198
 11199            return communityContexts.OrderBy(context => context, StringComparer.Ordinal).ToList();
 1200        }
 01201        catch (Exception ex)
 1202        {
 01203            _logger.LogError(ex, "Failed to get available community contexts");
 01204            throw;
 1205        }
 11206    }
 1207
 1208    private string? SerializeJustification(PredictionJustification? justification)
 1209    {
 11210        if (justification == null)
 1211        {
 11212            return null;
 1213        }
 1214
 11215        if (!HasJustificationContent(justification))
 1216        {
 11217            return null;
 1218        }
 1219
 11220        var stored = new StoredJustification
 11221        {
 11222            KeyReasoning = justification.KeyReasoning?.Trim() ?? string.Empty,
 11223            ContextSources = new StoredContextSources
 11224            {
 11225                MostValuable = justification.ContextSources?.MostValuable?
 11226                    .Where(entry => entry != null)
 11227                    .Select(ToStoredContextSource)
 11228                    .ToList() ?? new List<StoredContextSource>(),
 11229                LeastValuable = justification.ContextSources?.LeastValuable?
 11230                    .Where(entry => entry != null)
 11231                    .Select(ToStoredContextSource)
 11232                    .ToList() ?? new List<StoredContextSource>()
 11233            },
 11234            Uncertainties = justification.Uncertainties?
 11235                .Where(item => !string.IsNullOrWhiteSpace(item))
 11236                .Select(item => item.Trim())
 11237                .ToList() ?? new List<string>()
 11238        };
 1239
 11240        return JsonSerializer.Serialize(stored, JustificationSerializerOptions);
 1241    }
 1242
 1243    private static bool HasJustificationContent(PredictionJustification justification)
 1244    {
 11245        if (!string.IsNullOrWhiteSpace(justification.KeyReasoning))
 1246        {
 11247            return true;
 1248        }
 1249
 11250        if (justification.ContextSources?.MostValuable != null &&
 11251            justification.ContextSources.MostValuable.Any(HasSourceContent))
 1252        {
 11253            return true;
 1254        }
 1255
 11256        if (justification.ContextSources?.LeastValuable != null &&
 11257            justification.ContextSources.LeastValuable.Any(HasSourceContent))
 1258        {
 11259            return true;
 1260        }
 1261
 11262        return justification.Uncertainties != null &&
 11263               justification.Uncertainties.Any(item => !string.IsNullOrWhiteSpace(item));
 1264    }
 1265
 1266    private static bool HasSourceContent(PredictionJustificationContextSource source)
 1267    {
 11268        return !string.IsNullOrWhiteSpace(source?.DocumentName) ||
 11269               !string.IsNullOrWhiteSpace(source?.Details);
 1270    }
 1271
 1272    private PredictionJustification? DeserializeJustification(string? serialized)
 1273    {
 11274        if (string.IsNullOrWhiteSpace(serialized))
 1275        {
 11276            return null;
 1277        }
 1278
 11279        var trimmed = serialized.Trim();
 1280
 11281        if (!trimmed.StartsWith("{"))
 1282        {
 01283            return new PredictionJustification(
 01284                trimmed,
 01285                new PredictionJustificationContextSources(
 01286                    Array.Empty<PredictionJustificationContextSource>(),
 01287                    Array.Empty<PredictionJustificationContextSource>()),
 01288                Array.Empty<string>());
 1289        }
 1290
 1291        try
 1292        {
 11293            var stored = JsonSerializer.Deserialize<StoredJustification>(trimmed, JustificationSerializerOptions);
 1294
 11295            if (stored == null)
 1296            {
 01297                return null;
 1298            }
 1299
 11300            var contextSources = stored.ContextSources ?? new StoredContextSources();
 1301
 11302            var mostValuable = contextSources.MostValuable?
 11303                .Where(entry => entry != null)
 11304                .Select(ToDomainContextSource)
 11305                .ToList() ?? new List<PredictionJustificationContextSource>();
 1306
 11307            var leastValuable = contextSources.LeastValuable?
 11308                .Where(entry => entry != null)
 11309                .Select(ToDomainContextSource)
 11310                .ToList() ?? new List<PredictionJustificationContextSource>();
 1311
 11312            var uncertainties = stored.Uncertainties?
 11313                .Where(item => !string.IsNullOrWhiteSpace(item))
 11314                .Select(item => item.Trim())
 11315                .ToList() ?? new List<string>();
 1316
 11317            var justification = new PredictionJustification(
 11318                stored.KeyReasoning?.Trim() ?? string.Empty,
 11319                new PredictionJustificationContextSources(mostValuable, leastValuable),
 11320                uncertainties);
 1321
 11322            return HasJustificationContent(justification) ? justification : null;
 1323        }
 01324        catch (JsonException ex)
 1325        {
 01326            _logger.LogWarning(ex, "Failed to parse structured justification JSON; falling back to legacy text format");
 1327
 01328            var fallbackJustification = new PredictionJustification(
 01329                trimmed,
 01330                new PredictionJustificationContextSources(
 01331                    Array.Empty<PredictionJustificationContextSource>(),
 01332                    Array.Empty<PredictionJustificationContextSource>()),
 01333                Array.Empty<string>());
 1334
 01335            return HasJustificationContent(fallbackJustification) ? fallbackJustification : null;
 1336        }
 11337    }
 1338
 1339    private static StoredContextSource ToStoredContextSource(PredictionJustificationContextSource source)
 1340    {
 11341        return new StoredContextSource
 11342        {
 11343            DocumentName = source.DocumentName?.Trim() ?? string.Empty,
 11344            Details = source.Details?.Trim() ?? string.Empty
 11345        };
 1346    }
 1347
 1348    private static PredictionJustificationContextSource ToDomainContextSource(StoredContextSource source)
 1349    {
 11350        var documentName = source.DocumentName?.Trim() ?? string.Empty;
 11351        var details = source.Details?.Trim() ?? string.Empty;
 11352        return new PredictionJustificationContextSource(documentName, details);
 1353    }
 1354
 1355    private sealed class StoredJustification
 1356    {
 11357        public string? KeyReasoning { get; set; }
 11358        public StoredContextSources? ContextSources { get; set; }
 11359        public List<string>? Uncertainties { get; set; }
 1360    }
 1361
 1362    private sealed class StoredContextSources
 1363    {
 11364        public List<StoredContextSource>? MostValuable { get; set; }
 11365        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}

Methods/Properties

.cctor()
.ctor(Google.Cloud.Firestore.FirestoreDb, Microsoft.Extensions.Logging.ILogger<FirebaseAdapter.FirebasePredictionRepository>)
SavePredictionAsync()
GetPredictionAsync()
GetPredictionAsync()
GetPredictionMetadataAsync()
GetMatchDayAsync()
GetStoredMatchAsync()
GetMatchDayWithPredictionsAsync()
GetAllPredictionsAsync()
HasPredictionAsync()
SaveBonusPredictionAsync()
GetBonusPredictionAsync()
GetBonusPredictionByTextAsync()
GetBonusPredictionMetadataByTextAsync()
GetAllBonusPredictionsAsync()
HasBonusPredictionAsync()
StoreMatchAsync()
ConvertToTimestamp(NodaTime.ZonedDateTime)
ConvertFromTimestamp(Google.Cloud.Firestore.Timestamp)
GetMatchRepredictionIndexAsync()
GetCancelledMatchPredictionAsync()
GetCancelledMatchPredictionMetadataAsync()
GetCancelledMatchRepredictionIndexAsync()
GetBonusRepredictionIndexAsync()
SaveRepredictionAsync()
SaveBonusRepredictionAsync()
GetMatchPredictionCostsByRepredictionIndexAsync()
GetBonusPredictionCostsByRepredictionIndexAsync()
GetAvailableMatchdaysAsync()
GetAvailableModelsAsync()
GetAvailableCommunityContextsAsync()
SerializeJustification(EHonda.KicktippAi.Core.PredictionJustification)
HasJustificationContent(EHonda.KicktippAi.Core.PredictionJustification)
HasSourceContent(EHonda.KicktippAi.Core.PredictionJustificationContextSource)
DeserializeJustification(string)
ToStoredContextSource(EHonda.KicktippAi.Core.PredictionJustificationContextSource)
ToDomainContextSource(FirebaseAdapter.FirebasePredictionRepository.StoredContextSource)
get_KeyReasoning()
set_KeyReasoning(string)
get_ContextSources()
set_ContextSources(FirebaseAdapter.FirebasePredictionRepository.StoredContextSources)
get_Uncertainties()
set_Uncertainties(System.Collections.Generic.List<string>)
get_MostValuable()
set_MostValuable(System.Collections.Generic.List<FirebaseAdapter.FirebasePredictionRepository.StoredContextSource>)
get_LeastValuable()
set_LeastValuable(System.Collections.Generic.List<FirebaseAdapter.FirebasePredictionRepository.StoredContextSource>)
get_DocumentName()
set_DocumentName(string)
get_Details()
set_Details(string)