< Summary

Information
Class: KicktippIntegration.KicktippClient
Assembly: KicktippIntegration
File(s): /home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/KicktippClient.cs
Line coverage
86%
Covered lines: 833
Uncovered lines: 135
Coverable lines: 968
Total lines: 2043
Line coverage: 86%
Branch coverage
78%
Covered branches: 594
Total branches: 752
Branch coverage: 78.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%66100%
GetOpenPredictionsAsync()71.88%363284.91%
PlaceBetAsync()85.9%847890%
PlaceBetsAsync()84.09%918892.86%
GetStandingsAsync()70.31%666492.11%
GetMatchesWithHistoryAsync()95%212087.1%
GetHomeAwayHistoryAsync()90%222083.33%
GetHeadToHeadHistoryAsync()90%222082.61%
GetHeadToHeadDetailedHistoryAsync()90%222081.82%
IsMatchOnPage(...)75%191676.92%
ExtractMatchWithHistoryFromSpielinfoPage(...)64.71%623471.11%
ExtractTeamHistory(...)75%1308481.36%
ExtractHeadToHeadHistory(...)61.36%774474.36%
FindNextMatchLink(...)75%9872.22%
ParseMatchDateTime(...)75%5464.29%
IsCancelledTimeText(...)100%11100%
GetPlacedPredictionsAsync()77.08%504890.63%
ExtractMatchdayFromPage(...)93.75%171683.33%
GetOpenBonusQuestionsAsync()80%404093.88%
GetPlacedBonusPredictionsAsync()85.29%353492.31%
PlaceBonusPredictionsAsync()85%626091.43%
ExpandAnnotation(...)66.67%7675%
Dispose()0%2040%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/KicktippClient.cs

#LineLine coverage
 1using System.Net;
 2using AngleSharp;
 3using AngleSharp.Dom;
 4using AngleSharp.Html.Dom;
 5using EHonda.KicktippAi.Core;
 6using Microsoft.Extensions.Caching.Memory;
 7using Microsoft.Extensions.Logging;
 8using NodaTime;
 9using NodaTime.Extensions;
 10
 11namespace KicktippIntegration;
 12
 13/// <summary>
 14/// Implementation of IKicktippClient for interacting with kicktipp.de website
 15/// Authentication is handled automatically via KicktippAuthenticationHandler
 16/// </summary>
 17public class KicktippClient : IKicktippClient, IDisposable
 18{
 19    private readonly HttpClient _httpClient;
 20    private readonly ILogger<KicktippClient> _logger;
 21    private readonly IBrowsingContext _browsingContext;
 22    private readonly IMemoryCache _cache;
 23
 124    public KicktippClient(HttpClient httpClient, ILogger<KicktippClient> logger, IMemoryCache cache)
 25    {
 126        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
 127        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 128        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
 29
 130        var config = Configuration.Default.WithDefaultLoader();
 131        _browsingContext = BrowsingContext.New(config);
 132    }
 33
 34    /// <inheritdoc />
 35    public async Task<List<Match>> GetOpenPredictionsAsync(string community)
 36    {
 37        try
 38        {
 139            var url = $"{community}/tippabgabe";
 140            var response = await _httpClient.GetAsync(url);
 41
 142            if (!response.IsSuccessStatusCode)
 43            {
 144                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 145                return new List<Match>();
 46            }
 47
 148            var content = await response.Content.ReadAsStringAsync();
 149            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 50
 151            var matches = new List<Match>();
 52
 53            // Extract matchday from the page
 154            var currentMatchday = ExtractMatchdayFromPage(document);
 155            _logger.LogDebug("Extracted matchday: {Matchday}", currentMatchday);
 56
 57            // Parse matches from the tippabgabe table
 158            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 159            if (matchTable == null)
 60            {
 161                _logger.LogWarning("Could not find tippabgabe table");
 162                return matches;
 63            }
 64
 165            var matchRows = matchTable.QuerySelectorAll("tr");
 166            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 67
 168            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 69
 170            foreach (var row in matchRows)
 71            {
 72                try
 73                {
 174                    var cells = row.QuerySelectorAll("td");
 175                    if (cells.Length >= 4)
 76                    {
 77                        // Extract match details from table cells
 178                        var timeText = cells[0].TextContent?.Trim() ?? "";
 179                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 180                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 81
 82                        // Check if match is cancelled ("Abgesagt" in German)
 83                        // Cancelled matches still accept predictions on Kicktipp, so we process them.
 84                        // See docs/features/cancelled-matches.md for design rationale.
 185                        var isCancelled = IsCancelledTimeText(timeText);
 86
 87                        // Handle date inheritance: if timeText is empty or cancelled, use the last valid time
 88                        // This preserves database key consistency (startsAt is part of the composite key)
 189                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 90                        {
 191                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 92                            {
 193                                if (isCancelled)
 94                                {
 195                                    _logger.LogWarning(
 196                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 197                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 198                                        homeTeam, awayTeam, lastValidTimeText);
 99                                }
 100                                else
 101                                {
 1102                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 103                                }
 1104                                timeText = lastValidTimeText;
 105                            }
 106                            else
 107                            {
 0108                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 0109                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 110                            }
 111                        }
 112                        else
 113                        {
 114                            // Update the last valid time for future inheritance
 1115                            lastValidTimeText = timeText;
 1116                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 117                        }
 118
 119                        // Check if this row has betting inputs (indicates open match)
 1120                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 1121                        if (bettingInputs.Length >= 2)
 122                        {
 1123                            _logger.LogDebug("Found open match: {HomeTeam} vs {AwayTeam} at {Time}{Cancelled}",
 1124                                homeTeam, awayTeam, timeText, isCancelled ? " (CANCELLED)" : "");
 125
 126                            // Parse the date/time - for now use a simple approach
 127                            // Format appears to be "08.07.25 21:00"
 1128                            var startsAt = ParseMatchDateTime(timeText);
 129
 1130                            matches.Add(new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled));
 131                        }
 132                    }
 1133                }
 0134                catch (Exception ex)
 135                {
 0136                    _logger.LogWarning(ex, "Error parsing match row");
 0137                    continue;
 138                }
 139            }
 140
 1141            _logger.LogInformation("Successfully parsed {MatchCount} open matches", matches.Count);
 1142            return matches;
 143        }
 0144        catch (Exception ex)
 145        {
 0146            _logger.LogError(ex, "Exception in GetOpenPredictionsAsync");
 0147            return new List<Match>();
 148        }
 1149    }
 150
 151    /// <inheritdoc />
 152    public async Task<bool> PlaceBetAsync(string community, Match match, BetPrediction prediction, bool overrideBet = fa
 153    {
 154        try
 155        {
 1156            var url = $"{community}/tippabgabe";
 1157            var response = await _httpClient.GetAsync(url);
 158
 1159            if (!response.IsSuccessStatusCode)
 160            {
 1161                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 1162                return false;
 163            }
 164
 1165            var pageContent = await response.Content.ReadAsStringAsync();
 1166            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 167
 168            // Find the bet form
 1169            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 1170            if (betForm == null)
 171            {
 1172                _logger.LogWarning("Could not find betting form on the page");
 1173                return false;
 174            }
 175
 176            // Find the main content area
 1177            var contentArea = document.QuerySelector("#kicktipp-content");
 1178            if (contentArea == null)
 179            {
 1180                _logger.LogWarning("Could not find content area on the betting page");
 1181                return false;
 182            }
 183
 184            // Find the table with predictions
 1185            var tbody = contentArea.QuerySelector("tbody");
 1186            if (tbody == null)
 187            {
 1188                _logger.LogWarning("No betting table found");
 1189                return false;
 190            }
 191
 1192            var rows = tbody.QuerySelectorAll("tr");
 1193            var formData = new List<KeyValuePair<string, string>>();
 1194            var matchFound = false;
 195
 196            // Copy hidden inputs from the original form
 1197            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 1198            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 199            {
 1200                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 201                {
 1202                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 203                }
 204            }
 205
 206            // Find the specific match in the form and set its bet
 1207            foreach (var row in rows)
 208            {
 1209                var cells = row.QuerySelectorAll("td");
 1210                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 211
 212                try
 213                {
 1214                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1215                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 216
 1217                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 0218                        continue;
 219
 220                    // Check if this is the match we want to bet on
 1221                    if (homeTeam == match.HomeTeam && roadTeam == match.AwayTeam)
 222                    {
 223                        // Find bet input fields in the row
 1224                        var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1225                        var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 226
 1227                        if (homeInput == null || awayInput == null)
 228                        {
 1229                            _logger.LogWarning("No betting inputs found for {Match}, skipping", match);
 1230                            continue;
 231                        }
 232
 233                        // Check if bets are already placed
 1234                        var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 1235                        var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 236
 1237                        if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBet)
 238                        {
 1239                            var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 1240                            _logger.LogInformation("{Match} - skipped, already placed {ExistingBet}", match, existingBet
 1241                            return true; // Consider this successful - bet already exists
 242                        }
 243
 244                        // Add bet to form data
 1245                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 246                        {
 1247                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString(
 1248                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString(
 1249                            matchFound = true;
 1250                            _logger.LogInformation("{Match} - betting {Prediction}", match, prediction);
 251                        }
 252                        else
 253                        {
 0254                            _logger.LogWarning("{Match} - input field names are missing, skipping", match);
 0255                            continue;
 256                        }
 257
 1258                        break; // Found our match, no need to continue
 259                    }
 1260                }
 0261                catch (Exception ex)
 262                {
 0263                    _logger.LogError(ex, "Error processing betting row");
 0264                    continue;
 265                }
 266            }
 267
 1268            if (!matchFound)
 269            {
 1270                _logger.LogWarning("Match {Match} not found in betting form", match);
 1271                return false;
 272            }
 273
 274            // Add other input fields that might have existing values
 1275            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 1276            foreach (var input in allInputs)
 277            {
 1278                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 279                {
 280                    // Only add if we haven't already added this field
 1281                    if (!formData.Any(kv => kv.Key == input.Name))
 282                    {
 1283                        formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 284                    }
 285                }
 286            }
 287
 288            // Find submit button
 1289            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 1290            var submitName = "submitbutton"; // Default from Python
 291
 1292            if (submitButton != null)
 293            {
 1294                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 295                {
 1296                    submitName = inputSubmit.Name;
 1297                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 298                }
 1299                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 300                {
 1301                    submitName = buttonSubmit.Name;
 1302                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 303                }
 304            }
 305            else
 306            {
 307                // Fallback to default submit button name
 1308                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 309            }
 310
 311            // Submit form
 1312            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 1313                (betForm.Action.StartsWith("http") ? betForm.Action :
 1314                 betForm.Action.StartsWith("/") ? betForm.Action :
 1315                 $"{community}/{betForm.Action}");
 316
 1317            var formContent = new FormUrlEncodedContent(formData);
 1318            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 319
 1320            if (submitResponse.IsSuccessStatusCode)
 321            {
 1322                _logger.LogInformation("✓ Successfully submitted bet for {Match}!", match);
 1323                return true;
 324            }
 325            else
 326            {
 1327                _logger.LogError("✗ Failed to submit bet. Status: {StatusCode}", submitResponse.StatusCode);
 1328                return false;
 329            }
 330        }
 0331        catch (Exception ex)
 332        {
 0333            _logger.LogError(ex, "Exception during bet placement");
 0334            return false;
 335        }
 1336    }
 337
 338    /// <inheritdoc />
 339    public async Task<bool> PlaceBetsAsync(string community, Dictionary<Match, BetPrediction> bets, bool overrideBets = 
 340    {
 341        try
 342        {
 1343            var url = $"{community}/tippabgabe";
 1344            var response = await _httpClient.GetAsync(url);
 345
 1346            if (!response.IsSuccessStatusCode)
 347            {
 1348                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 1349                return false;
 350            }
 351
 1352            var pageContent = await response.Content.ReadAsStringAsync();
 1353            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 354
 355            // Find the bet form
 1356            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 1357            if (betForm == null)
 358            {
 1359                _logger.LogWarning("Could not find betting form on the page");
 1360                return false;
 361            }
 362
 363            // Find the main content area
 1364            var contentArea = document.QuerySelector("#kicktipp-content");
 1365            if (contentArea == null)
 366            {
 1367                _logger.LogWarning("Could not find content area on the betting page");
 1368                return false;
 369            }
 370
 371            // Find the table with predictions
 1372            var tbody = contentArea.QuerySelector("tbody");
 1373            if (tbody == null)
 374            {
 1375                _logger.LogWarning("No betting table found");
 1376                return false;
 377            }
 378
 1379            var rows = tbody.QuerySelectorAll("tr");
 1380            var formData = new List<KeyValuePair<string, string>>();
 1381            var betsPlaced = 0;
 1382            var betsSkipped = 0;
 383
 384            // Add hidden fields from the form
 1385            var hiddenInputs = betForm.QuerySelectorAll("input[type=hidden]").OfType<IHtmlInputElement>();
 1386            foreach (var input in hiddenInputs)
 387            {
 1388                if (!string.IsNullOrEmpty(input.Name) && input.Value != null)
 389                {
 1390                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 391                }
 392            }
 393
 394            // Process all matches in the form
 1395            foreach (var row in rows)
 396            {
 1397                var cells = row.QuerySelectorAll("td");
 1398                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 399
 400                try
 401                {
 1402                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1403                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 404
 1405                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 1406                        continue;
 407
 408                    // Check if we have a bet for this match
 1409                    var matchKey = bets.Keys.FirstOrDefault(m => m.HomeTeam == homeTeam && m.AwayTeam == roadTeam);
 1410                    if (matchKey == null)
 411                    {
 412                        // Add existing bet values to maintain form state
 1413                        var existingHomeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1414                        var existingAwayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 415
 1416                        if (existingHomeInput != null && existingAwayInput != null &&
 1417                            !string.IsNullOrEmpty(existingHomeInput.Name) && !string.IsNullOrEmpty(existingAwayInput.Nam
 418                        {
 1419                            formData.Add(new KeyValuePair<string, string>(existingHomeInput.Name, existingHomeInput.Valu
 1420                            formData.Add(new KeyValuePair<string, string>(existingAwayInput.Name, existingAwayInput.Valu
 421                        }
 1422                        continue;
 423                    }
 424
 1425                    var prediction = bets[matchKey];
 426
 427                    // Find bet input fields in the row
 1428                    var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1429                    var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 430
 1431                    if (homeInput == null || awayInput == null)
 432                    {
 1433                        _logger.LogWarning("No betting inputs found for {MatchKey}, skipping", matchKey);
 1434                        continue;
 435                    }
 436
 437                    // Check if bets are already placed
 1438                    var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 1439                    var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 440
 1441                    if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBets)
 442                    {
 1443                        var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 1444                        _logger.LogInformation("{MatchKey} - skipped, already placed {ExistingBet}", matchKey, existingB
 1445                        betsSkipped++;
 446
 447                        // Keep existing values
 1448                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 449                        {
 1450                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, homeInput.Value ?? ""));
 1451                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, awayInput.Value ?? ""));
 452                        }
 1453                        continue;
 454                    }
 455
 456                    // Add bet to form data
 1457                    if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 458                    {
 1459                        formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString()));
 1460                        formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString()));
 1461                        betsPlaced++;
 1462                        _logger.LogInformation("{MatchKey} - betting {Prediction}", matchKey, prediction);
 463                    }
 464                    else
 465                    {
 0466                        _logger.LogWarning("{MatchKey} - input field names are missing, skipping", matchKey);
 467                        continue;
 468                    }
 1469                }
 0470                catch (Exception ex)
 471                {
 0472                    _logger.LogError(ex, "Error processing betting row");
 0473                    continue;
 474                }
 475            }
 476
 1477            _logger.LogInformation("Summary: {BetsPlaced} bets to place, {BetsSkipped} skipped", betsPlaced, betsSkipped
 478
 1479            if (betsPlaced == 0)
 480            {
 1481                _logger.LogInformation("No bets to place");
 1482                return true;
 483            }
 484
 485            // Find submit button
 1486            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 1487            var submitName = "submitbutton"; // Default from Python
 488
 1489            if (submitButton != null)
 490            {
 1491                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 492                {
 1493                    submitName = inputSubmit.Name;
 1494                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 495                }
 1496                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 497                {
 1498                    submitName = buttonSubmit.Name;
 1499                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 500                }
 501            }
 502            else
 503            {
 504                // Fallback to default submit button name
 1505                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 506            }
 507
 508            // Submit form
 1509            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 1510                (betForm.Action.StartsWith("http") ? betForm.Action :
 1511                 betForm.Action.StartsWith("/") ? betForm.Action :
 1512                 $"{community}/{betForm.Action}");
 513
 1514            var formContent = new FormUrlEncodedContent(formData);
 1515            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 516
 1517            if (submitResponse.IsSuccessStatusCode)
 518            {
 1519                _logger.LogInformation("✓ Successfully submitted {BetsPlaced} bets!", betsPlaced);
 1520                return true;
 521            }
 522            else
 523            {
 1524                _logger.LogError("✗ Failed to submit bets. Status: {StatusCode}", submitResponse.StatusCode);
 1525                return false;
 526            }
 527        }
 0528        catch (Exception ex)
 529        {
 0530            _logger.LogError(ex, "Exception during bet placement");
 0531            return false;
 532        }
 1533    }
 534
 535    /// <inheritdoc />
 536    public async Task<List<TeamStanding>> GetStandingsAsync(string community)
 537    {
 538        // Create cache key based on community
 1539        var cacheKey = $"standings_{community}";
 540
 541        // Try to get from cache first
 1542        if (_cache.TryGetValue(cacheKey, out List<TeamStanding>? cachedStandings))
 543        {
 1544            _logger.LogDebug("Retrieved standings for {Community} from cache", community);
 1545            return cachedStandings!;
 546        }
 547
 548        try
 549        {
 1550            var url = $"{community}/tabellen";
 1551            var response = await _httpClient.GetAsync(url);
 552
 1553            if (!response.IsSuccessStatusCode)
 554            {
 1555                _logger.LogError("Failed to fetch standings page. Status: {StatusCode}", response.StatusCode);
 1556                return new List<TeamStanding>();
 557            }
 558
 1559            var content = await response.Content.ReadAsStringAsync();
 1560            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 561
 1562            var standings = new List<TeamStanding>();
 563
 564            // Find the standings table
 1565            var standingsTable = document.QuerySelector("table.sporttabelle tbody");
 1566            if (standingsTable == null)
 567            {
 1568                _logger.LogWarning("Could not find standings table");
 1569                return standings;
 570            }
 571
 1572            var rows = standingsTable.QuerySelectorAll("tr");
 1573            _logger.LogDebug("Found {RowCount} team rows in standings table", rows.Length);
 574
 1575            foreach (var row in rows)
 576            {
 577                try
 578                {
 1579                    var cells = row.QuerySelectorAll("td");
 1580                    if (cells.Length >= 9) // Need at least 9 columns for all data
 581                    {
 582                        // Extract data from table cells
 1583                        var positionText = cells[0].TextContent?.Trim().TrimEnd('.') ?? "";
 1584                        var teamNameElement = cells[1].QuerySelector("div");
 1585                        var teamName = teamNameElement?.TextContent?.Trim() ?? "";
 1586                        var gamesPlayedText = cells[2].TextContent?.Trim() ?? "";
 1587                        var pointsText = cells[3].TextContent?.Trim() ?? "";
 1588                        var goalsText = cells[4].TextContent?.Trim() ?? "";
 1589                        var goalDifferenceText = cells[5].TextContent?.Trim() ?? "";
 1590                        var winsText = cells[6].TextContent?.Trim() ?? "";
 1591                        var drawsText = cells[7].TextContent?.Trim() ?? "";
 1592                        var lossesText = cells[8].TextContent?.Trim() ?? "";
 593
 594                        // Parse numeric values
 1595                        if (int.TryParse(positionText, out var position) &&
 1596                            int.TryParse(gamesPlayedText, out var gamesPlayed) &&
 1597                            int.TryParse(pointsText, out var points) &&
 1598                            int.TryParse(goalDifferenceText, out var goalDifference) &&
 1599                            int.TryParse(winsText, out var wins) &&
 1600                            int.TryParse(drawsText, out var draws) &&
 1601                            int.TryParse(lossesText, out var losses))
 602                        {
 603                            // Parse goals (format: "15:8")
 1604                            var goalsParts = goalsText.Split(':');
 1605                            var goalsFor = 0;
 1606                            var goalsAgainst = 0;
 607
 1608                            if (goalsParts.Length == 2)
 609                            {
 1610                                int.TryParse(goalsParts[0], out goalsFor);
 1611                                int.TryParse(goalsParts[1], out goalsAgainst);
 612                            }
 613
 1614                            var teamStanding = new TeamStanding(
 1615                                position,
 1616                                teamName,
 1617                                gamesPlayed,
 1618                                points,
 1619                                goalsFor,
 1620                                goalsAgainst,
 1621                                goalDifference,
 1622                                wins,
 1623                                draws,
 1624                                losses);
 625
 1626                            standings.Add(teamStanding);
 1627                            _logger.LogDebug("Parsed team standing: {Position}. {TeamName} - {Points} points",
 1628                                position, teamName, points);
 629                        }
 630                        else
 631                        {
 1632                            _logger.LogWarning("Failed to parse numeric values for team row");
 633                        }
 634                    }
 1635                }
 0636                catch (Exception ex)
 637                {
 0638                    _logger.LogWarning(ex, "Error parsing standings row");
 0639                    continue;
 640                }
 641            }
 642
 1643            _logger.LogInformation("Successfully parsed {StandingsCount} team standings", standings.Count);
 644
 645            // Cache the results for 20 minutes (standings change relatively infrequently)
 1646            var cacheOptions = new MemoryCacheEntryOptions
 1647            {
 1648                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20),
 1649                SlidingExpiration = TimeSpan.FromMinutes(10) // Reset timer if accessed within 10 minutes
 1650            };
 1651            _cache.Set(cacheKey, standings, cacheOptions);
 1652            _logger.LogDebug("Cached standings for {Community} for 20 minutes", community);
 653
 1654            return standings;
 655        }
 0656        catch (Exception ex)
 657        {
 0658            _logger.LogError(ex, "Exception in GetStandingsAsync");
 0659            return new List<TeamStanding>();
 660        }
 1661    }
 662
 663    /// <inheritdoc />
 664    public async Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community)
 665    {
 666        // Create cache key based on community
 1667        var cacheKey = $"matches_history_{community}";
 668
 669        // Try to get from cache first
 1670        if (_cache.TryGetValue(cacheKey, out List<MatchWithHistory>? cachedMatches))
 671        {
 1672            _logger.LogDebug("Retrieved matches with history for {Community} from cache", community);
 1673            return cachedMatches!;
 674        }
 675
 676        try
 677        {
 1678            var matches = new List<MatchWithHistory>();
 679
 680            // First, get the tippabgabe page to find the link to spielinfos
 1681            var tippabgabeUrl = $"{community}/tippabgabe";
 1682            var response = await _httpClient.GetAsync(tippabgabeUrl);
 683
 1684            if (!response.IsSuccessStatusCode)
 685            {
 1686                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1687                return matches;
 688            }
 689
 1690            var content = await response.Content.ReadAsStringAsync();
 1691            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 692
 693            // Extract matchday from the tippabgabe page
 1694            var currentMatchday = ExtractMatchdayFromPage(document);
 1695            _logger.LogDebug("Extracted matchday for history extraction: {Matchday}", currentMatchday);
 696
 697            // Find the "Tippabgabe mit Spielinfos" link
 1698            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1699            if (spielinfoLink == null)
 700            {
 1701                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1702                return matches;
 703            }
 704
 1705            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1706            if (string.IsNullOrEmpty(spielinfoUrl))
 707            {
 0708                _logger.LogWarning("Spielinfo link has no href attribute");
 0709                return matches;
 710            }
 711
 712            // Make URL absolute if it's relative
 1713            if (spielinfoUrl.StartsWith("/"))
 714            {
 1715                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 716            }
 717
 1718            _logger.LogInformation("Starting to fetch match details from spielinfo pages...");
 719
 720            // Navigate through all matches using the right arrow navigation
 1721            var currentUrl = spielinfoUrl;
 1722            var matchCount = 0;
 723
 1724            while (!string.IsNullOrEmpty(currentUrl))
 725            {
 726                try
 727                {
 1728                    var spielinfoResponse = await _httpClient.GetAsync(currentUrl);
 1729                    if (!spielinfoResponse.IsSuccessStatusCode)
 730                    {
 1731                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", currentUrl, sp
 1732                        break;
 733                    }
 734
 1735                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1736                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 737
 738                    // Extract match information
 1739                    var matchWithHistory = ExtractMatchWithHistoryFromSpielinfoPage(spielinfoDocument, currentMatchday);
 1740                    if (matchWithHistory != null)
 741                    {
 1742                        matches.Add(matchWithHistory);
 1743                        matchCount++;
 1744                        _logger.LogDebug("Extracted match {Count}: {Match}", matchCount, matchWithHistory.Match);
 745                    }
 746
 747                    // Find the next match link (right arrow)
 1748                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1749                    if (nextLink != null)
 750                    {
 1751                        currentUrl = nextLink;
 1752                        if (currentUrl.StartsWith("/"))
 753                        {
 1754                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 755                        }
 756                    }
 757                    else
 758                    {
 759                        // No more matches
 1760                        break;
 761                    }
 1762                }
 0763                catch (Exception ex)
 764                {
 0765                    _logger.LogError(ex, "Error processing spielinfo page: {Url}", currentUrl);
 0766                    break;
 767                }
 768            }
 769
 1770            _logger.LogInformation("Successfully extracted {MatchCount} matches with history", matches.Count);
 771
 772            // Cache the results for 15 minutes (match info changes less frequently than live scores)
 1773            var cacheOptions = new MemoryCacheEntryOptions
 1774            {
 1775                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
 1776                SlidingExpiration = TimeSpan.FromMinutes(7) // Reset timer if accessed within 7 minutes
 1777            };
 1778            _cache.Set(cacheKey, matches, cacheOptions);
 1779            _logger.LogDebug("Cached matches with history for {Community} for 15 minutes", community);
 780
 1781            return matches;
 782        }
 0783        catch (Exception ex)
 784        {
 0785            _logger.LogError(ex, "Exception in GetMatchesWithHistoryAsync");
 0786            return new List<MatchWithHistory>();
 787        }
 1788    }
 789
 790    /// <inheritdoc />
 791    public async Task<(List<MatchResult> homeTeamHomeHistory, List<MatchResult> awayTeamAwayHistory)> GetHomeAwayHistory
 792    {
 793        try
 794        {
 795            // First, get the tippabgabe page to find the link to spielinfos
 1796            var tippabgabeUrl = $"{community}/tippabgabe";
 1797            var response = await _httpClient.GetAsync(tippabgabeUrl);
 798
 1799            if (!response.IsSuccessStatusCode)
 800            {
 1801                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1802                return (new List<MatchResult>(), new List<MatchResult>());
 803            }
 804
 1805            var content = await response.Content.ReadAsStringAsync();
 1806            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 807
 808            // Find the "Tippabgabe mit Spielinfos" link
 1809            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1810            if (spielinfoLink == null)
 811            {
 1812                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1813                return (new List<MatchResult>(), new List<MatchResult>());
 814            }
 815
 1816            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1817            if (string.IsNullOrEmpty(spielinfoUrl))
 818            {
 0819                _logger.LogWarning("Spielinfo link has no href attribute");
 0820                return (new List<MatchResult>(), new List<MatchResult>());
 821            }
 822
 823            // Make URL absolute if it's relative
 1824            if (spielinfoUrl.StartsWith("/"))
 825            {
 1826                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 827            }
 828
 829            // Navigate through all matches using the right arrow navigation
 1830            var currentUrl = spielinfoUrl;
 831
 1832            while (!string.IsNullOrEmpty(currentUrl))
 833            {
 834                try
 835                {
 836                    // Add ansicht=2 parameter for home/away history
 1837                    var homeAwayUrl = currentUrl.Contains('?')
 1838                        ? $"{currentUrl}&ansicht=2"
 1839                        : $"{currentUrl}?ansicht=2";
 840
 1841                    var spielinfoResponse = await _httpClient.GetAsync(homeAwayUrl);
 1842                    if (!spielinfoResponse.IsSuccessStatusCode)
 843                    {
 1844                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", homeAwayUrl, s
 1845                        break;
 846                    }
 847
 1848                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1849                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 850
 851                    // Check if this page contains our match
 1852                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 853                    {
 854                        // Extract home team home history
 1855                        var homeTeamHomeHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoHeim");
 856
 857                        // Extract away team away history
 1858                        var awayTeamAwayHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoGast");
 859
 1860                        return (homeTeamHomeHistory, awayTeamAwayHistory);
 861                    }
 862
 863                    // Find the next match link (right arrow)
 1864                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1865                    if (nextLink != null)
 866                    {
 1867                        currentUrl = nextLink;
 1868                        if (currentUrl.StartsWith("/"))
 869                        {
 1870                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 871                        }
 872                    }
 873                    else
 874                    {
 875                        // No more matches
 1876                        break;
 877                    }
 1878                }
 0879                catch (Exception ex)
 880                {
 0881                    _logger.LogError(ex, "Error processing spielinfo page for home/away history: {CurrentUrl}", currentU
 0882                    break;
 883                }
 884            }
 885
 1886            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 1887            return (new List<MatchResult>(), new List<MatchResult>());
 888        }
 0889        catch (Exception ex)
 890        {
 0891            _logger.LogError(ex, "Exception in GetHomeAwayHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 0892            return (new List<MatchResult>(), new List<MatchResult>());
 893        }
 1894    }
 895
 896    /// <inheritdoc />
 897    public async Task<List<MatchResult>> GetHeadToHeadHistoryAsync(string community, string homeTeam, string awayTeam)
 898    {
 899        try
 900        {
 901            // First, get the tippabgabe page to find the link to spielinfos
 1902            var tippabgabeUrl = $"{community}/tippabgabe";
 1903            var response = await _httpClient.GetAsync(tippabgabeUrl);
 904
 1905            if (!response.IsSuccessStatusCode)
 906            {
 1907                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1908                return new List<MatchResult>();
 909            }
 910
 1911            var content = await response.Content.ReadAsStringAsync();
 1912            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 913
 914            // Find the "Tippabgabe mit Spielinfos" link
 1915            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1916            if (spielinfoLink == null)
 917            {
 1918                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1919                return new List<MatchResult>();
 920            }
 921
 1922            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1923            if (string.IsNullOrEmpty(spielinfoUrl))
 924            {
 0925                _logger.LogWarning("Spielinfo link has no href attribute");
 0926                return new List<MatchResult>();
 927            }
 928
 929            // Make URL absolute if it's relative
 1930            if (spielinfoUrl.StartsWith("/"))
 931            {
 1932                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 933            }
 934
 935            // Navigate through all matches using the right arrow navigation
 1936            var currentUrl = spielinfoUrl;
 937
 1938            while (!string.IsNullOrEmpty(currentUrl))
 939            {
 940                try
 941                {
 942                    // Add ansicht=3 parameter for head-to-head history
 1943                    var headToHeadUrl = currentUrl.Contains('?')
 1944                        ? $"{currentUrl}&ansicht=3"
 1945                        : $"{currentUrl}?ansicht=3";
 946
 1947                    var spielinfoResponse = await _httpClient.GetAsync(headToHeadUrl);
 1948                    if (!spielinfoResponse.IsSuccessStatusCode)
 949                    {
 1950                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", headToHeadUrl,
 1951                        break;
 952                    }
 953
 1954                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1955                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 956
 957                    // Check if this page contains our match
 1958                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 959                    {
 960                        // Extract head-to-head history
 1961                        return ExtractTeamHistory(spielinfoDocument, "spielinfoDirekterVergleich");
 962                    }
 963
 964                    // Find the next match link (right arrow)
 1965                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1966                    if (nextLink != null)
 967                    {
 1968                        currentUrl = nextLink;
 1969                        if (currentUrl.StartsWith("/"))
 970                        {
 1971                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 972                        }
 973                    }
 974                    else
 975                    {
 976                        // No more matches
 1977                        break;
 978                    }
 1979                }
 0980                catch (Exception ex)
 981                {
 0982                    _logger.LogError(ex, "Error processing spielinfo page for head-to-head history: {CurrentUrl}", curre
 0983                    break;
 984                }
 985            }
 986
 1987            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 1988            return new List<MatchResult>();
 989        }
 0990        catch (Exception ex)
 991        {
 0992            _logger.LogError(ex, "Exception in GetHeadToHeadHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTe
 0993            return new List<MatchResult>();
 994        }
 1995    }
 996
 997    /// <inheritdoc />
 998    public async Task<List<HeadToHeadResult>> GetHeadToHeadDetailedHistoryAsync(string community, string homeTeam, strin
 999    {
 1000        try
 1001        {
 1002            // First, get the tippabgabe page to find the link to spielinfos
 11003            var tippabgabeUrl = $"{community}/tippabgabe";
 11004            var response = await _httpClient.GetAsync(tippabgabeUrl);
 1005
 11006            if (!response.IsSuccessStatusCode)
 1007            {
 11008                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 11009                return new List<HeadToHeadResult>();
 1010            }
 1011
 11012            var content = await response.Content.ReadAsStringAsync();
 11013            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1014
 1015            // Find the "Tippabgabe mit Spielinfos" link
 11016            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 11017            if (spielinfoLink == null)
 1018            {
 11019                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 11020                return new List<HeadToHeadResult>();
 1021            }
 1022
 11023            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 11024            if (string.IsNullOrEmpty(spielinfoUrl))
 1025            {
 01026                _logger.LogWarning("Spielinfo link has no href attribute");
 01027                return new List<HeadToHeadResult>();
 1028            }
 1029
 1030            // Make URL absolute if it's relative
 11031            if (spielinfoUrl.StartsWith("/"))
 1032            {
 11033                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 1034            }
 1035
 1036            // Navigate through all matches using the right arrow navigation
 11037            var currentUrl = spielinfoUrl;
 1038
 11039            while (!string.IsNullOrEmpty(currentUrl))
 1040            {
 1041                try
 1042                {
 1043                    // Append ansicht=3 to get head-to-head view
 11044                    var urlWithAnsicht = currentUrl.Contains('?') ? $"{currentUrl}&ansicht=3" : $"{currentUrl}?ansicht=3
 11045                    var spielinfoResponse = await _httpClient.GetAsync(urlWithAnsicht);
 1046
 11047                    if (!spielinfoResponse.IsSuccessStatusCode)
 1048                    {
 11049                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", urlWithAnsicht
 11050                        break;
 1051                    }
 1052
 11053                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 11054                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1055
 1056                    // Check if this page contains our match
 11057                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1058                    {
 1059                        // Extract head-to-head history from this page
 11060                        return ExtractHeadToHeadHistory(spielinfoDocument);
 1061                    }
 1062
 1063                    // Find the next match link (right arrow)
 11064                    var nextLink = FindNextMatchLink(spielinfoDocument);
 11065                    if (nextLink != null)
 1066                    {
 11067                        currentUrl = nextLink;
 11068                        if (currentUrl.StartsWith("/"))
 1069                        {
 11070                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1071                        }
 1072                    }
 1073                    else
 1074                    {
 11075                        break;
 1076                    }
 11077                }
 01078                catch (Exception ex)
 1079                {
 01080                    _logger.LogWarning(ex, "Error processing spielinfo page: {Url}", currentUrl);
 01081                    break;
 1082                }
 1083            }
 1084
 11085            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 11086            return new List<HeadToHeadResult>();
 1087        }
 01088        catch (Exception ex)
 1089        {
 01090            _logger.LogError(ex, "Exception in GetHeadToHeadDetailedHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam
 01091            return new List<HeadToHeadResult>();
 1092        }
 11093    }
 1094    private bool IsMatchOnPage(IDocument document, string homeTeam, string awayTeam)
 1095    {
 1096        try
 1097        {
 1098            // Look for the match in the tippabgabe table
 11099            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 1100
 11101            foreach (var row in matchRows)
 1102            {
 11103                var cells = row.QuerySelectorAll("td");
 11104                if (cells.Length >= 3)
 1105                {
 11106                    var pageHomeTeam = cells[1].TextContent?.Trim() ?? "";
 11107                    var pageAwayTeam = cells[2].TextContent?.Trim() ?? "";
 1108
 11109                    if (pageHomeTeam == homeTeam && pageAwayTeam == awayTeam)
 1110                    {
 11111                        return true;
 1112                    }
 1113                }
 1114            }
 1115
 11116            return false;
 1117        }
 01118        catch (Exception ex)
 1119        {
 01120            _logger.LogDebug(ex, "Error checking if match is on page");
 01121            return false;
 1122        }
 11123    }
 1124
 1125    private MatchWithHistory? ExtractMatchWithHistoryFromSpielinfoPage(IDocument document, int matchday)
 1126    {
 1127        try
 1128        {
 1129            // Extract match information from the tippabgabe table
 1130            // Look for all rows in the table, not just the first one
 11131            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 11132            if (matchRows.Length == 0)
 1133            {
 01134                _logger.LogWarning("Could not find any match rows in tippabgabe table on spielinfo page");
 01135                return null;
 1136            }
 1137
 11138            _logger.LogDebug("Found {RowCount} rows in tippabgabe table", matchRows.Length);
 1139
 1140            // Find the row that contains match data (has input fields for betting)
 11141            IElement? matchRow = null;
 11142            foreach (var row in matchRows)
 1143            {
 11144                var rowCells = row.QuerySelectorAll("td");
 11145                if (rowCells.Length >= 4)
 1146                {
 1147                    // Check if this row has betting inputs (indicates it's the match row)
 11148                    var bettingInputs = rowCells[3].QuerySelectorAll("input[type='text']");
 11149                    if (bettingInputs.Length >= 2)
 1150                    {
 11151                        matchRow = row;
 11152                        break;
 1153                    }
 1154                }
 1155            }
 1156
 11157            if (matchRow == null)
 1158            {
 11159                _logger.LogWarning("Could not find match row with betting inputs in tippabgabe table");
 11160                return null;
 1161            }
 1162
 11163            var cells = matchRow.QuerySelectorAll("td");
 11164            if (cells.Length < 4)
 1165            {
 01166                _logger.LogWarning("Match row does not have enough cells");
 01167                return null;
 1168            }
 1169
 11170            _logger.LogDebug("Found {CellCount} cells in match row", cells.Length);
 11171            for (int i = 0; i < Math.Min(cells.Length, 5); i++)
 1172            {
 11173                _logger.LogDebug("Cell[{Index}]: '{Content}' (Class: '{Class}')", i, cells[i].TextContent?.Trim(), cells
 1174            }
 1175
 11176            var timeText = cells[0].TextContent?.Trim() ?? "";
 11177            var homeTeam = cells[1].TextContent?.Trim() ?? "";
 11178            var awayTeam = cells[2].TextContent?.Trim() ?? "";
 1179
 11180            _logger.LogDebug("Extracted from spielinfo page - Time: '{TimeText}', Home: '{HomeTeam}', Away: '{AwayTeam}'
 1181
 11182            if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(awayTeam))
 1183            {
 01184                _logger.LogWarning("Could not extract team names from match table");
 01185                return null;
 1186            }
 1187
 1188            // Check if match is cancelled ("Abgesagt" in German)
 1189            // Note: On spielinfo pages, cancelled matches may still show - process them with IsCancelled flag
 11190            var isCancelled = IsCancelledTimeText(timeText);
 11191            if (isCancelled)
 1192            {
 01193                _logger.LogWarning(
 01194                    "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt) on spielinfo page. " +
 01195                    "Using current time as fallback since spielinfo doesn't provide time inheritance context.",
 01196                    homeTeam, awayTeam);
 1197            }
 1198
 11199            var startsAt = ParseMatchDateTime(timeText);
 11200            var match = new Match(homeTeam, awayTeam, startsAt, matchday, isCancelled);
 1201
 1202            // Extract home team history
 11203            var homeTeamHistory = ExtractTeamHistory(document, "spielinfoHeim");
 1204
 1205            // Extract away team history
 11206            var awayTeamHistory = ExtractTeamHistory(document, "spielinfoGast");
 1207
 11208            return new MatchWithHistory(match, homeTeamHistory, awayTeamHistory);
 1209        }
 01210        catch (Exception ex)
 1211        {
 01212            _logger.LogError(ex, "Error extracting match with history from spielinfo page");
 01213            return null;
 1214        }
 11215    }
 1216
 1217    private List<MatchResult> ExtractTeamHistory(IDocument document, string tableClass)
 1218    {
 11219        var results = new List<MatchResult>();
 1220
 1221        try
 1222        {
 11223            var table = document.QuerySelector($"table.{tableClass} tbody");
 11224            if (table == null)
 1225            {
 01226                _logger.LogDebug("Could not find team history table with class: {TableClass}", tableClass);
 01227                return results;
 1228            }
 1229
 11230            var rows = table.QuerySelectorAll("tr");
 11231            foreach (var row in rows)
 1232            {
 1233                try
 1234                {
 11235                    var cells = row.QuerySelectorAll("td");
 1236
 1237                    // Handle different table formats
 1238                    string competition, homeTeam, awayTeam;
 11239                    var resultCell = cells.Last(); // Result is always in the last cell
 11240                    var homeGoals = (int?)null;
 11241                    var awayGoals = (int?)null;
 11242                    var outcome = MatchOutcome.Pending;
 11243                    string? annotation = null;
 1244
 11245                    if (tableClass == "spielinfoDirekterVergleich")
 1246                    {
 1247                        // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 11248                        if (cells.Length < 6)
 01249                            continue;
 1250
 11251                        competition = $"{cells[0].TextContent?.Trim()} {cells[1].TextContent?.Trim()}";
 11252                        homeTeam = cells[3].TextContent?.Trim() ?? "";
 11253                        awayTeam = cells[4].TextContent?.Trim() ?? "";
 1254                    }
 1255                    else
 1256                    {
 1257                        // Standard format: Competition | Home | Away | Result
 11258                        if (cells.Length < 4)
 01259                            continue;
 1260
 11261                        competition = cells[0].TextContent?.Trim() ?? "";
 11262                        homeTeam = cells[1].TextContent?.Trim() ?? "";
 11263                        awayTeam = cells[2].TextContent?.Trim() ?? "";
 1264                    }
 1265
 1266                    // Parse the score from the result cell
 11267                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 11268                    if (scoreElements.Length >= 2)
 1269                    {
 11270                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 11271                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1272
 11273                        if (homeScoreText != "-" && awayScoreText != "-")
 1274                        {
 11275                            if (int.TryParse(homeScoreText, out var homeScore) && int.TryParse(awayScoreText, out var aw
 1276                            {
 11277                                homeGoals = homeScore;
 11278                                awayGoals = awayScore;
 1279
 1280                                // Determine outcome from team's perspective based on CSS classes
 11281                                var homeTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[3] : cells[1];
 11282                                var awayTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[4] : cells[2];
 1283
 11284                                var isHomeTeam = homeTeamCell.ClassList.Contains("sieg") || homeTeamCell.ClassList.Conta
 11285                                var isAwayTeam = awayTeamCell.ClassList.Contains("sieg") || awayTeamCell.ClassList.Conta
 1286
 11287                                if (isHomeTeam)
 1288                                {
 11289                                    outcome = homeScore > awayScore ? MatchOutcome.Win :
 11290                                             homeScore < awayScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1291                                }
 11292                                else if (isAwayTeam)
 1293                                {
 11294                                    outcome = awayScore > homeScore ? MatchOutcome.Win :
 11295                                             awayScore < homeScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1296                                }
 1297                                else
 1298                                {
 1299                                    // Fallback: determine from score (neutral perspective)
 11300                                    outcome = homeScore == awayScore ? MatchOutcome.Draw :
 11301                                             homeScore > awayScore ? MatchOutcome.Win : MatchOutcome.Loss;
 1302                                }
 1303                            }
 1304                        }
 1305                    }
 1306
 1307                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 11308                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 11309                    if (annotationElement != null)
 1310                    {
 11311                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1312                    }
 1313
 11314                    var matchResult = new MatchResult(competition, homeTeam, awayTeam, homeGoals, awayGoals, outcome, an
 11315                    results.Add(matchResult);
 11316                }
 01317                catch (Exception ex)
 1318                {
 01319                    _logger.LogDebug(ex, "Error parsing team history row");
 01320                    continue;
 1321                }
 1322            }
 11323        }
 01324        catch (Exception ex)
 1325        {
 01326            _logger.LogError(ex, "Error extracting team history for table class: {TableClass}", tableClass);
 01327        }
 1328
 11329        return results;
 01330    }
 1331
 1332    private List<HeadToHeadResult> ExtractHeadToHeadHistory(IDocument document)
 1333    {
 11334        var results = new List<HeadToHeadResult>();
 1335
 1336        try
 1337        {
 11338            var table = document.QuerySelector("table.spielinfoDirekterVergleich tbody");
 11339            if (table == null)
 1340            {
 01341                _logger.LogDebug("Could not find head-to-head table with class: spielinfoDirekterVergleich");
 01342                return results;
 1343            }
 1344
 11345            var rows = table.QuerySelectorAll("tr");
 11346            foreach (var row in rows)
 1347            {
 1348                try
 1349                {
 11350                    var cells = row.QuerySelectorAll("td");
 1351
 1352                    // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 11353                    if (cells.Length < 6)
 01354                        continue;
 1355
 11356                    var league = cells[0].TextContent?.Trim() ?? "";
 11357                    var matchday = cells[1].TextContent?.Trim() ?? "";
 11358                    var playedAt = cells[2].TextContent?.Trim() ?? "";
 11359                    var homeTeam = cells[3].TextContent?.Trim() ?? "";
 11360                    var awayTeam = cells[4].TextContent?.Trim() ?? "";
 1361
 1362                    // Extract score from the result cell
 11363                    var resultCell = cells[5];
 11364                    var score = "";
 11365                    string? annotation = null;
 1366
 11367                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 11368                    if (scoreElements.Length >= 2)
 1369                    {
 11370                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 11371                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1372
 11373                        if (homeScoreText != "-" && awayScoreText != "-")
 1374                        {
 11375                            score = $"{homeScoreText}:{awayScoreText}";
 1376                        }
 1377                    }
 1378
 1379                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 11380                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 11381                    if (annotationElement != null)
 1382                    {
 11383                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1384                    }
 1385
 11386                    var headToHeadResult = new HeadToHeadResult(league, matchday, playedAt, homeTeam, awayTeam, score, a
 11387                    results.Add(headToHeadResult);
 11388                }
 01389                catch (Exception ex)
 1390                {
 01391                    _logger.LogDebug(ex, "Error parsing head-to-head row");
 01392                    continue;
 1393                }
 1394            }
 11395        }
 01396        catch (Exception ex)
 1397        {
 01398            _logger.LogError(ex, "Error extracting head-to-head history");
 01399        }
 1400
 11401        return results;
 01402    }
 1403
 1404    private string? FindNextMatchLink(IDocument document)
 1405    {
 1406        try
 1407        {
 1408            // Look for the right arrow button in the match navigation
 11409            var nextButton = document.QuerySelector(".prevnextNext a");
 11410            if (nextButton == null)
 1411            {
 11412                _logger.LogDebug("No next match button found");
 11413                return null;
 1414            }
 1415
 1416            // Check if the button is disabled
 11417            var parentDiv = nextButton.ParentElement;
 11418            if (parentDiv?.ClassList.Contains("disabled") == true)
 1419            {
 11420                _logger.LogDebug("Next match button is disabled - reached end of matches");
 11421                return null;
 1422            }
 1423
 11424            var href = nextButton.GetAttribute("href");
 11425            if (string.IsNullOrEmpty(href))
 1426            {
 01427                _logger.LogDebug("Next match button has no href");
 01428                return null;
 1429            }
 1430
 11431            _logger.LogDebug("Found next match link: {Href}", href);
 11432            return href;
 1433        }
 01434        catch (Exception ex)
 1435        {
 01436            _logger.LogError(ex, "Error finding next match link");
 01437            return null;
 1438        }
 11439    }
 1440
 1441    private ZonedDateTime ParseMatchDateTime(string timeText)
 1442    {
 1443        try
 1444        {
 1445            // Handle empty or null time text
 1446            // Use MinValue to ensure database key consistency and prevent orphaned predictions
 1447            // See docs/features/cancelled-matches.md for design rationale
 11448            if (string.IsNullOrWhiteSpace(timeText))
 1449            {
 11450                _logger.LogWarning("Match time text is empty, using MinValue for database consistency");
 11451                return DateTimeOffset.MinValue.ToZonedDateTime();
 1452            }
 1453
 1454            // Expected format: "22.08.25 20:30"
 11455            _logger.LogDebug("Attempting to parse time: '{TimeText}'", timeText);
 11456            if (DateTime.TryParseExact(timeText, "dd.MM.yy HH:mm", null, System.Globalization.DateTimeStyles.None, out v
 1457            {
 11458                _logger.LogDebug("Successfully parsed time: {DateTime}", dateTime);
 1459                // Convert to DateTimeOffset and then to ZonedDateTime
 1460                // Assume Central European Time (Germany)
 11461                var dateTimeOffset = new DateTimeOffset(dateTime, TimeSpan.FromHours(1)); // CET offset
 11462                return dateTimeOffset.ToZonedDateTime();
 1463            }
 1464
 1465            // Fallback to MinValue if parsing fails - ensures database key consistency
 1466            // and prevents orphaned predictions from being created with varying timestamps
 1467            // See docs/features/cancelled-matches.md for design rationale
 01468            _logger.LogWarning("Could not parse match time: '{TimeText}', using MinValue for database consistency", time
 01469            return DateTimeOffset.MinValue.ToZonedDateTime();
 1470        }
 01471        catch (Exception ex)
 1472        {
 01473            _logger.LogError(ex, "Error parsing match time '{TimeText}'", timeText);
 01474            return DateTimeOffset.MinValue.ToZonedDateTime();
 1475        }
 11476    }
 1477
 1478    /// <summary>
 1479    /// Determines if the given time text indicates a cancelled match.
 1480    /// </summary>
 1481    /// <param name="timeText">The time text from the Kicktipp page.</param>
 1482    /// <returns>True if the match is cancelled ("Abgesagt" in German), false otherwise.</returns>
 1483    /// <remarks>
 1484    /// <para>
 1485    /// Cancelled matches on Kicktipp display "Abgesagt" instead of a date/time in the schedule.
 1486    /// These matches can still receive predictions, so we continue processing them rather than skipping.
 1487    /// </para>
 1488    /// <para>
 1489    /// <b>Design Decision:</b> We treat "Abgesagt" similar to an empty time cell and inherit the
 1490    /// previous valid time. This preserves database key consistency since the composite key
 1491    /// (HomeTeam, AwayTeam, StartsAt, ...) must remain stable across prediction operations.
 1492    /// </para>
 1493    /// <para>
 1494    /// See <c>docs/features/cancelled-matches.md</c> for complete design rationale.
 1495    /// </para>
 1496    /// </remarks>
 1497    private static bool IsCancelledTimeText(string timeText)
 1498    {
 11499        return string.Equals(timeText, "Abgesagt", StringComparison.OrdinalIgnoreCase);
 1500    }
 1501
 1502    /// <inheritdoc />
 1503    public async Task<Dictionary<Match, BetPrediction?>> GetPlacedPredictionsAsync(string community)
 1504    {
 1505        try
 1506        {
 11507            var url = $"{community}/tippabgabe";
 11508            var response = await _httpClient.GetAsync(url);
 1509
 11510            if (!response.IsSuccessStatusCode)
 1511            {
 11512                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 11513                return new Dictionary<Match, BetPrediction?>();
 1514            }
 1515
 11516            var content = await response.Content.ReadAsStringAsync();
 11517            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1518
 11519            var placedPredictions = new Dictionary<Match, BetPrediction?>();
 1520
 1521            // Extract matchday from the page
 11522            var currentMatchday = ExtractMatchdayFromPage(document);
 11523            _logger.LogDebug("Extracted matchday for placed predictions: {Matchday}", currentMatchday);
 1524
 1525            // Parse matches from the tippabgabe table
 11526            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 11527            if (matchTable == null)
 1528            {
 11529                _logger.LogWarning("Could not find tippabgabe table");
 11530                return placedPredictions;
 1531            }
 1532
 11533            var matchRows = matchTable.QuerySelectorAll("tr");
 11534            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 1535
 11536            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 1537
 11538            foreach (var row in matchRows)
 1539            {
 1540                try
 1541                {
 11542                    var cells = row.QuerySelectorAll("td");
 11543                    if (cells.Length >= 4)
 1544                    {
 1545                        // Extract match details from table cells
 11546                        var timeText = cells[0].TextContent?.Trim() ?? "";
 11547                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 11548                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 1549
 11550                        _logger.LogDebug("Raw time text for {HomeTeam} vs {AwayTeam}: '{TimeText}'", homeTeam, awayTeam,
 1551
 1552                        // Check if match is cancelled ("Abgesagt" in German)
 1553                        // Cancelled matches still accept predictions on Kicktipp, so we process them.
 1554                        // See docs/features/cancelled-matches.md for design rationale.
 11555                        var isCancelled = IsCancelledTimeText(timeText);
 1556
 1557                        // Handle date inheritance: if timeText is empty or cancelled, use the last valid time
 1558                        // This preserves database key consistency (startsAt is part of the composite key)
 11559                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 1560                        {
 11561                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 1562                            {
 11563                                if (isCancelled)
 1564                                {
 11565                                    _logger.LogWarning(
 11566                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 11567                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 11568                                        homeTeam, awayTeam, lastValidTimeText);
 1569                                }
 1570                                else
 1571                                {
 11572                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 1573                                }
 11574                                timeText = lastValidTimeText;
 1575                            }
 1576                            else
 1577                            {
 11578                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 11579                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 1580                            }
 1581                        }
 1582                        else
 1583                        {
 1584                            // Update the last valid time for future inheritance
 11585                            lastValidTimeText = timeText;
 11586                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 1587                        }
 1588
 1589                        // Look for betting inputs to get placed predictions
 11590                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 11591                        if (bettingInputs.Length >= 2)
 1592                        {
 11593                            var homeInput = bettingInputs[0] as IHtmlInputElement;
 11594                            var awayInput = bettingInputs[1] as IHtmlInputElement;
 1595
 1596                            // Parse the date/time
 11597                            var startsAt = ParseMatchDateTime(timeText);
 11598                            var match = new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled);
 1599
 1600                            // Check if predictions are placed (inputs have values)
 11601                            var homeValue = homeInput?.Value?.Trim();
 11602                            var awayValue = awayInput?.Value?.Trim();
 1603
 11604                            BetPrediction? prediction = null;
 11605                            if (!string.IsNullOrEmpty(homeValue) && !string.IsNullOrEmpty(awayValue))
 1606                            {
 11607                                if (int.TryParse(homeValue, out var homeGoals) && int.TryParse(awayValue, out var awayGo
 1608                                {
 11609                                    prediction = new BetPrediction(homeGoals, awayGoals);
 11610                                    _logger.LogDebug("Found placed prediction: {HomeTeam} vs {AwayTeam} = {Prediction}",
 1611                                }
 1612                                else
 1613                                {
 11614                                    _logger.LogWarning("Could not parse prediction values for {HomeTeam} vs {AwayTeam}: 
 1615                                }
 1616                            }
 1617                            else
 1618                            {
 11619                                _logger.LogDebug("No prediction placed for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 1620                            }
 1621
 11622                            placedPredictions[match] = prediction;
 1623                        }
 1624                    }
 11625                }
 01626                catch (Exception ex)
 1627                {
 01628                    _logger.LogWarning(ex, "Error parsing match row");
 01629                    continue;
 1630                }
 1631            }
 1632
 11633            _logger.LogInformation("Successfully parsed {MatchCount} matches with {PlacedCount} placed predictions",
 11634                placedPredictions.Count, placedPredictions.Values.Count(p => p != null));
 11635            return placedPredictions;
 1636        }
 01637        catch (Exception ex)
 1638        {
 01639            _logger.LogError(ex, "Exception in GetPlacedPredictionsAsync");
 01640            return new Dictionary<Match, BetPrediction?>();
 1641        }
 11642    }
 1643
 1644    private int ExtractMatchdayFromPage(IDocument document)
 1645    {
 1646        try
 1647        {
 1648            // Try to extract from the navigation title (e.g., "1. Spieltag")
 11649            var titleElement = document.QuerySelector(".prevnextTitle a");
 11650            if (titleElement != null)
 1651            {
 11652                var titleText = titleElement.TextContent?.Trim();
 11653                if (!string.IsNullOrEmpty(titleText))
 1654                {
 1655                    // Extract number from text like "1. Spieltag"
 11656                    var match = System.Text.RegularExpressions.Regex.Match(titleText, @"(\d+)\.\s*Spieltag");
 11657                    if (match.Success && int.TryParse(match.Groups[1].Value, out var matchday))
 1658                    {
 11659                        _logger.LogDebug("Extracted matchday from title: {Matchday}", matchday);
 11660                        return matchday;
 1661                    }
 1662                }
 1663            }
 1664
 1665            // Fallback: try to extract from hidden input
 11666            var spieltagInput = document.QuerySelector("input[name='spieltagIndex']") as IHtmlInputElement;
 11667            if (spieltagInput?.Value != null && int.TryParse(spieltagInput.Value, out var matchdayFromInput))
 1668            {
 11669                _logger.LogDebug("Extracted matchday from hidden input: {Matchday}", matchdayFromInput);
 11670                return matchdayFromInput;
 1671            }
 1672
 11673            _logger.LogWarning("Could not extract matchday from page, defaulting to 1");
 11674            return 1;
 1675        }
 01676        catch (Exception ex)
 1677        {
 01678            _logger.LogError(ex, "Error extracting matchday from page, defaulting to 1");
 01679            return 1;
 1680        }
 11681    }
 1682
 1683    /// <inheritdoc />
 1684    public async Task<List<BonusQuestion>> GetOpenBonusQuestionsAsync(string community)
 1685    {
 1686        try
 1687        {
 11688            var url = $"{community}/tippabgabe?bonus=true";
 11689            var response = await _httpClient.GetAsync(url);
 1690
 11691            if (!response.IsSuccessStatusCode)
 1692            {
 11693                _logger.LogError("Failed to fetch tippabgabe page for bonus questions. Status: {StatusCode}", response.S
 11694                return new List<BonusQuestion>();
 1695            }
 1696
 11697            var content = await response.Content.ReadAsStringAsync();
 11698            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1699
 11700            var bonusQuestions = new List<BonusQuestion>();
 1701
 1702            // Parse bonus questions from the tippabgabeFragen table
 11703            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 11704            if (bonusTable == null)
 1705            {
 11706                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 11707                return bonusQuestions;
 1708            }
 1709
 11710            var questionRows = bonusTable.QuerySelectorAll("tr");
 11711            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows", questionRows.Length);
 1712
 11713            foreach (var row in questionRows)
 1714            {
 11715                var cells = row.QuerySelectorAll("td");
 11716                if (cells.Length < 3) continue;
 1717
 1718                // Extract deadline and question text
 11719                var deadlineText = cells[0]?.TextContent?.Trim();
 11720                var questionText = cells[1]?.TextContent?.Trim();
 1721
 11722                if (string.IsNullOrEmpty(questionText)) continue;
 1723
 1724                // Parse deadline
 11725                var deadline = ParseMatchDateTime(deadlineText ?? "");
 1726
 1727                // Extract options from select elements
 11728                var tipCell = cells[2];
 11729                var selectElements = tipCell?.QuerySelectorAll("select");
 11730                var options = new List<BonusQuestionOption>();
 11731                string? formFieldName = null;
 11732                int maxSelections = 1; // Default to single selection
 1733
 11734                if (selectElements != null && selectElements.Length > 0)
 1735                {
 1736                    // The number of select elements indicates how many selections are allowed
 11737                    maxSelections = selectElements.Length;
 1738
 1739                    // Use the first select element to get the available options
 11740                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 11741                    formFieldName = firstSelect?.Name;
 1742
 11743                    var optionElements = firstSelect?.QuerySelectorAll("option");
 11744                    if (optionElements != null)
 1745                    {
 11746                        foreach (var option in optionElements.Cast<IHtmlOptionElement>())
 1747                        {
 11748                            if (option.Value != "-1" && !string.IsNullOrEmpty(option.Text))
 1749                            {
 11750                                options.Add(new BonusQuestionOption(option.Value, option.Text.Trim()));
 1751                            }
 1752                        }
 1753                    }
 1754                }
 1755
 11756                if (options.Any())
 1757                {
 11758                    bonusQuestions.Add(new BonusQuestion(
 11759                        Text: questionText,
 11760                        Deadline: deadline,
 11761                        Options: options,
 11762                        MaxSelections: maxSelections,
 11763                        FormFieldName: formFieldName
 11764                    ));
 1765                }
 1766            }
 1767
 11768            _logger.LogInformation("Successfully parsed {QuestionCount} bonus questions", bonusQuestions.Count);
 11769            return bonusQuestions;
 1770        }
 01771        catch (Exception ex)
 1772        {
 01773            _logger.LogError(ex, "Exception in GetOpenBonusQuestionsAsync");
 01774            return new List<BonusQuestion>();
 1775        }
 11776    }
 1777
 1778    /// <inheritdoc />
 1779    public async Task<Dictionary<string, BonusPrediction?>> GetPlacedBonusPredictionsAsync(string community)
 1780    {
 1781        try
 1782        {
 11783            var url = $"{community}/tippabgabe?bonus=true";
 11784            var response = await _httpClient.GetAsync(url);
 1785
 11786            if (!response.IsSuccessStatusCode)
 1787            {
 11788                _logger.LogError("Failed to fetch tippabgabe page for placed bonus predictions. Status: {StatusCode}", r
 11789                return new Dictionary<string, BonusPrediction?>();
 1790            }
 1791
 11792            var content = await response.Content.ReadAsStringAsync();
 11793            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1794
 11795            var placedPredictions = new Dictionary<string, BonusPrediction?>();
 1796
 1797            // Parse bonus questions from the tippabgabeFragen table
 11798            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 11799            if (bonusTable == null)
 1800            {
 11801                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 11802                return placedPredictions;
 1803            }
 1804
 11805            var questionRows = bonusTable.QuerySelectorAll("tr");
 11806            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows for placed predictions", questionRo
 1807
 11808            foreach (var row in questionRows)
 1809            {
 11810                var cells = row.QuerySelectorAll("td");
 11811                if (cells.Length < 3) continue;
 1812
 1813                // Extract question text
 11814                var questionText = cells[1]?.TextContent?.Trim();
 11815                if (string.IsNullOrEmpty(questionText)) continue;
 1816
 1817                // Extract current selections from select elements
 11818                var tipCell = cells[2];
 11819                var selectElements = tipCell?.QuerySelectorAll("select");
 1820
 11821                if (selectElements != null && selectElements.Length > 0)
 1822                {
 1823                    // Extract form field name from the first select element
 11824                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 11825                    var formFieldName = firstSelect?.Name;
 1826
 11827                    var selectedOptionIds = new List<string>();
 1828
 1829                    // Check each select element for its current selection
 11830                    foreach (var selectElement in selectElements.Cast<IHtmlSelectElement>())
 1831                    {
 11832                        var selectedOption = selectElement.SelectedOptions.FirstOrDefault();
 11833                        if (selectedOption != null && selectedOption.Value != "-1" && !string.IsNullOrEmpty(selectedOpti
 1834                        {
 11835                            selectedOptionIds.Add(selectedOption.Value);
 1836                        }
 1837                    }
 1838
 1839                    // Use form field name as key, fall back to question text
 11840                    var dictionaryKey = formFieldName ?? questionText;
 1841
 1842                    // Only create a prediction if there are actual selections
 11843                    if (selectedOptionIds.Any())
 1844                    {
 11845                        placedPredictions[dictionaryKey] = new BonusPrediction(selectedOptionIds);
 1846                    }
 1847                    else
 1848                    {
 11849                        placedPredictions[dictionaryKey] = null; // No prediction placed
 1850                    }
 1851                }
 1852            }
 1853
 11854            _logger.LogInformation("Successfully retrieved placed predictions for {QuestionCount} bonus questions", plac
 11855            return placedPredictions;
 1856        }
 01857        catch (Exception ex)
 1858        {
 01859            _logger.LogError(ex, "Exception in GetPlacedBonusPredictionsAsync");
 01860            return new Dictionary<string, BonusPrediction?>();
 1861        }
 11862    }
 1863
 1864    /// <inheritdoc />
 1865    public async Task<bool> PlaceBonusPredictionsAsync(string community, Dictionary<string, BonusPrediction> predictions
 1866    {
 1867        try
 1868        {
 11869            if (!predictions.Any())
 1870            {
 11871                _logger.LogInformation("No bonus predictions to place");
 11872                return true;
 1873            }
 1874
 11875            var url = $"{community}/tippabgabe?bonus=true";
 11876            var response = await _httpClient.GetAsync(url);
 1877
 11878            if (!response.IsSuccessStatusCode)
 1879            {
 11880                _logger.LogError("Failed to access betting page for bonus predictions. Status: {StatusCode}", response.S
 11881                return false;
 1882            }
 1883
 11884            var pageContent = await response.Content.ReadAsStringAsync();
 11885            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 1886
 1887            // Find the bet form
 11888            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 11889            if (betForm == null)
 1890            {
 11891                _logger.LogWarning("Could not find betting form on the page");
 11892                return false;
 1893            }
 1894
 11895            var formData = new List<KeyValuePair<string, string>>();
 1896
 1897            // Copy hidden inputs from the original form
 11898            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 11899            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 1900            {
 11901                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 1902                {
 11903                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 1904                }
 1905            }
 1906
 1907            // Copy existing match predictions to avoid overwriting them
 11908            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 11909            foreach (var input in allInputs)
 1910            {
 11911                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 1912                {
 01913                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 1914                }
 1915            }
 1916
 1917            // Add bonus predictions
 11918            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 11919            if (bonusTable != null)
 1920            {
 11921                var questionRows = bonusTable.QuerySelectorAll("tr");
 1922
 11923                foreach (var row in questionRows)
 1924                {
 11925                    var cells = row.QuerySelectorAll("td");
 11926                    if (cells.Length < 3) continue;
 1927
 11928                    var tipCell = cells[2];
 11929                    var selectElements = tipCell?.QuerySelectorAll("select");
 1930
 11931                    if (selectElements != null)
 1932                    {
 11933                        var selectArray = selectElements.Cast<IHtmlSelectElement>().ToArray();
 1934
 1935                        // Check if we have a prediction for this question based on form field name match
 11936                        var matchingPrediction = predictions.FirstOrDefault(p =>
 11937                            selectArray.Any(sel => sel.Name == p.Key) ||
 11938                            selectArray.Any(sel => sel.Name?.Contains(p.Key) == true));
 1939
 11940                        if (matchingPrediction.Value != null && matchingPrediction.Value.SelectedOptionIds.Any())
 1941                        {
 11942                            var selectedOptions = matchingPrediction.Value.SelectedOptionIds;
 1943
 1944                            // For multi-selection questions, we need to fill multiple select elements
 11945                            for (int i = 0; i < Math.Min(selectArray.Length, selectedOptions.Count); i++)
 1946                            {
 11947                                var selectElement = selectArray[i];
 11948                                var fieldName = selectElement.Name;
 11949                                if (string.IsNullOrEmpty(fieldName)) continue;
 1950
 11951                                var selectedOptionId = selectedOptions[i];
 1952
 1953                                // Check if this option exists in the select element
 11954                                var optionExists = selectElement.QuerySelectorAll("option")
 11955                                    .Cast<IHtmlOptionElement>()
 11956                                    .Any(opt => opt.Value == selectedOptionId);
 1957
 11958                                if (optionExists)
 1959                                {
 11960                                    formData.Add(new KeyValuePair<string, string>(fieldName, selectedOptionId));
 11961                                    _logger.LogDebug("Added bonus prediction for field {FieldName}: {OptionId} (selectio
 11962                                        fieldName, selectedOptionId, i + 1);
 1963                                }
 1964                                else
 1965                                {
 01966                                    _logger.LogWarning("Option {OptionId} not found for field {FieldName}", selectedOpti
 1967                                }
 1968                            }
 1969                        }
 1970                    }
 1971                }
 1972            }
 1973
 1974            // Find submit button
 11975            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 11976            if (submitButton != null)
 1977            {
 11978                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 1979                {
 11980                    formData.Add(new KeyValuePair<string, string>(inputSubmit.Name, inputSubmit.Value ?? "Submit"));
 1981                }
 11982                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 1983                {
 11984                    formData.Add(new KeyValuePair<string, string>(buttonSubmit.Name, buttonSubmit.Value ?? "Submit"));
 1985                }
 1986            }
 1987            else
 1988            {
 1989                // Fallback to default submit button name
 01990                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 1991            }
 1992
 1993            // Submit form
 11994            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 11995                (betForm.Action.StartsWith("http") ? betForm.Action :
 11996                 betForm.Action.StartsWith("/") ? betForm.Action :
 11997                 $"{community}/{betForm.Action}");
 1998
 11999            var formContent = new FormUrlEncodedContent(formData);
 12000            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 2001
 12002            if (submitResponse.IsSuccessStatusCode)
 2003            {
 12004                _logger.LogInformation("✓ Successfully submitted {PredictionCount} bonus predictions!", predictions.Coun
 12005                return true;
 2006            }
 2007            else
 2008            {
 12009                _logger.LogError("✗ Failed to submit bonus predictions. Status: {StatusCode}", submitResponse.StatusCode
 12010                return false;
 2011            }
 2012        }
 02013        catch (Exception ex)
 2014        {
 02015            _logger.LogError(ex, "Exception during bonus prediction placement");
 02016            return false;
 2017        }
 12018    }
 2019
 2020    /// <summary>
 2021    /// Expands match annotation abbreviations to their full text.
 2022    /// </summary>
 2023    /// <param name="annotation">The abbreviated annotation (e.g., "n.E.", "n.V.")</param>
 2024    /// <returns>The expanded annotation or null if empty</returns>
 2025    private static string? ExpandAnnotation(string? annotation)
 2026    {
 12027        if (string.IsNullOrWhiteSpace(annotation))
 02028            return null;
 2029
 12030        return annotation.Trim() switch
 12031        {
 12032            "n.E." => "nach Elfmeterschießen",
 12033            "n.V." => "nach Verlängerung",
 02034            _ => annotation.Trim() // Return as-is if not recognized
 12035        };
 2036    }
 2037
 2038    public void Dispose()
 2039    {
 02040        _httpClient?.Dispose();
 02041        _browsingContext?.Dispose();
 02042    }
 2043}