< Summary

Information
Class: KicktippIntegration.KicktippClient.CompletedRankingEventMapping
Assembly: KicktippIntegration
File(s): /home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/KicktippClient.cs
Line coverage
100%
Covered lines: 4
Uncovered lines: 0
Coverable lines: 4
Total lines: 2629
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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

File(s)

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

#LineLine coverage
 1using System.Net;
 2using System.Globalization;
 3using Regex = System.Text.RegularExpressions.Regex;
 4using AngleSharp;
 5using AngleSharp.Dom;
 6using AngleSharp.Html.Dom;
 7using EHonda.KicktippAi.Core;
 8using Microsoft.Extensions.Caching.Memory;
 9using Microsoft.Extensions.Logging;
 10using NodaTime;
 11using NodaTime.Extensions;
 12
 13namespace KicktippIntegration;
 14
 15/// <summary>
 16/// Implementation of IKicktippClient for interacting with kicktipp.de website
 17/// Authentication is handled automatically via KicktippAuthenticationHandler
 18/// </summary>
 19public class KicktippClient : IKicktippClient, IDisposable
 20{
 21    private static readonly DateTimeZone BerlinTimeZone = DateTimeZoneProviders.Tzdb["Europe/Berlin"];
 22
 23    private readonly HttpClient _httpClient;
 24    private readonly ILogger<KicktippClient> _logger;
 25    private readonly IBrowsingContext _browsingContext;
 26    private readonly IMemoryCache _cache;
 27
 28    public KicktippClient(HttpClient httpClient, ILogger<KicktippClient> logger, IMemoryCache cache)
 29    {
 30        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
 31        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 32        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
 33
 34        var config = Configuration.Default.WithDefaultLoader();
 35        _browsingContext = BrowsingContext.New(config);
 36    }
 37
 38    /// <inheritdoc />
 39    public async Task<List<Match>> GetOpenPredictionsAsync(string community)
 40    {
 41        try
 42        {
 43            var url = $"{community}/tippabgabe";
 44            var response = await _httpClient.GetAsync(url);
 45
 46            if (!response.IsSuccessStatusCode)
 47            {
 48                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 49                return new List<Match>();
 50            }
 51
 52            var content = await response.Content.ReadAsStringAsync();
 53            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 54
 55            var matches = new List<Match>();
 56
 57            // Extract matchday from the page
 58            var currentMatchday = ExtractMatchdayFromPage(document);
 59            _logger.LogDebug("Extracted matchday: {Matchday}", currentMatchday);
 60
 61            // Parse matches from the tippabgabe table
 62            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 63            if (matchTable == null)
 64            {
 65                _logger.LogWarning("Could not find tippabgabe table");
 66                return matches;
 67            }
 68
 69            var matchRows = matchTable.QuerySelectorAll("tr");
 70            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 71
 72            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 73
 74            foreach (var row in matchRows)
 75            {
 76                try
 77                {
 78                    var cells = row.QuerySelectorAll("td");
 79                    if (cells.Length >= 4)
 80                    {
 81                        // Extract match details from table cells
 82                        var timeText = cells[0].TextContent?.Trim() ?? "";
 83                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 84                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 85
 86                        // Check if match is cancelled ("Abgesagt" in German)
 87                        // Cancelled matches still accept predictions on Kicktipp, so we process them.
 88                        // See docs/features/cancelled-matches.md for design rationale.
 89                        var isCancelled = IsCancelledTimeText(timeText);
 90
 91                        // Handle date inheritance: if timeText is empty or cancelled, use the last valid time
 92                        // This preserves database key consistency (startsAt is part of the composite key)
 93                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 94                        {
 95                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 96                            {
 97                                if (isCancelled)
 98                                {
 99                                    _logger.LogWarning(
 100                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 101                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 102                                        homeTeam, awayTeam, lastValidTimeText);
 103                                }
 104                                else
 105                                {
 106                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 107                                }
 108                                timeText = lastValidTimeText;
 109                            }
 110                            else
 111                            {
 112                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 113                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 114                            }
 115                        }
 116                        else
 117                        {
 118                            // Update the last valid time for future inheritance
 119                            lastValidTimeText = timeText;
 120                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 121                        }
 122
 123                        // Check if this row has betting inputs (indicates open match)
 124                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 125                        if (bettingInputs.Length >= 2)
 126                        {
 127                            _logger.LogDebug("Found open match: {HomeTeam} vs {AwayTeam} at {Time}{Cancelled}",
 128                                homeTeam, awayTeam, timeText, isCancelled ? " (CANCELLED)" : "");
 129
 130                            // Parse the date/time - for now use a simple approach
 131                            // Format appears to be "08.07.25 21:00"
 132                            var startsAt = ParseMatchDateTime(timeText);
 133
 134                            matches.Add(new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled));
 135                        }
 136                    }
 137                }
 138                catch (Exception ex)
 139                {
 140                    _logger.LogWarning(ex, "Error parsing match row");
 141                    continue;
 142                }
 143            }
 144
 145            _logger.LogInformation("Successfully parsed {MatchCount} open matches", matches.Count);
 146            return matches;
 147        }
 148        catch (Exception ex)
 149        {
 150            _logger.LogError(ex, "Exception in GetOpenPredictionsAsync");
 151            return new List<Match>();
 152        }
 153    }
 154
 155    /// <inheritdoc />
 156    public async Task<bool> PlaceBetAsync(string community, Match match, BetPrediction prediction, bool overrideBet = fa
 157    {
 158        try
 159        {
 160            var url = $"{community}/tippabgabe";
 161            var response = await _httpClient.GetAsync(url);
 162
 163            if (!response.IsSuccessStatusCode)
 164            {
 165                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 166                return false;
 167            }
 168
 169            var pageContent = await response.Content.ReadAsStringAsync();
 170            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 171
 172            // Find the bet form
 173            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 174            if (betForm == null)
 175            {
 176                _logger.LogWarning("Could not find betting form on the page");
 177                return false;
 178            }
 179
 180            // Find the main content area
 181            var contentArea = document.QuerySelector("#kicktipp-content");
 182            if (contentArea == null)
 183            {
 184                _logger.LogWarning("Could not find content area on the betting page");
 185                return false;
 186            }
 187
 188            // Find the table with predictions
 189            var tbody = contentArea.QuerySelector("tbody");
 190            if (tbody == null)
 191            {
 192                _logger.LogWarning("No betting table found");
 193                return false;
 194            }
 195
 196            var rows = tbody.QuerySelectorAll("tr");
 197            var formData = new List<KeyValuePair<string, string>>();
 198            var matchFound = false;
 199
 200            // Copy hidden inputs from the original form
 201            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 202            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 203            {
 204                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 205                {
 206                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 207                }
 208            }
 209
 210            // Find the specific match in the form and set its bet
 211            foreach (var row in rows)
 212            {
 213                var cells = row.QuerySelectorAll("td");
 214                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 215
 216                try
 217                {
 218                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 219                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 220
 221                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 222                        continue;
 223
 224                    // Check if this is the match we want to bet on
 225                    if (homeTeam == match.HomeTeam && roadTeam == match.AwayTeam)
 226                    {
 227                        // Find bet input fields in the row
 228                        var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 229                        var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 230
 231                        if (homeInput == null || awayInput == null)
 232                        {
 233                            _logger.LogWarning("No betting inputs found for {Match}, skipping", match);
 234                            continue;
 235                        }
 236
 237                        // Check if bets are already placed
 238                        var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 239                        var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 240
 241                        if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBet)
 242                        {
 243                            var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 244                            _logger.LogInformation("{Match} - skipped, already placed {ExistingBet}", match, existingBet
 245                            return true; // Consider this successful - bet already exists
 246                        }
 247
 248                        // Add bet to form data
 249                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 250                        {
 251                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString(
 252                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString(
 253                            matchFound = true;
 254                            _logger.LogInformation("{Match} - betting {Prediction}", match, prediction);
 255                        }
 256                        else
 257                        {
 258                            _logger.LogWarning("{Match} - input field names are missing, skipping", match);
 259                            continue;
 260                        }
 261
 262                        break; // Found our match, no need to continue
 263                    }
 264                }
 265                catch (Exception ex)
 266                {
 267                    _logger.LogError(ex, "Error processing betting row");
 268                    continue;
 269                }
 270            }
 271
 272            if (!matchFound)
 273            {
 274                _logger.LogWarning("Match {Match} not found in betting form", match);
 275                return false;
 276            }
 277
 278            // Add other input fields that might have existing values
 279            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 280            foreach (var input in allInputs)
 281            {
 282                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 283                {
 284                    // Only add if we haven't already added this field
 285                    if (!formData.Any(kv => kv.Key == input.Name))
 286                    {
 287                        formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 288                    }
 289                }
 290            }
 291
 292            // Find submit button
 293            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 294            var submitName = "submitbutton"; // Default from Python
 295
 296            if (submitButton != null)
 297            {
 298                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 299                {
 300                    submitName = inputSubmit.Name;
 301                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 302                }
 303                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 304                {
 305                    submitName = buttonSubmit.Name;
 306                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 307                }
 308            }
 309            else
 310            {
 311                // Fallback to default submit button name
 312                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 313            }
 314
 315            // Submit form
 316            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 317                (betForm.Action.StartsWith("http") ? betForm.Action :
 318                 betForm.Action.StartsWith("/") ? betForm.Action :
 319                 $"{community}/{betForm.Action}");
 320
 321            var formContent = new FormUrlEncodedContent(formData);
 322            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 323
 324            if (submitResponse.IsSuccessStatusCode)
 325            {
 326                _logger.LogInformation("✓ Successfully submitted bet for {Match}!", match);
 327                return true;
 328            }
 329            else
 330            {
 331                _logger.LogError("✗ Failed to submit bet. Status: {StatusCode}", submitResponse.StatusCode);
 332                return false;
 333            }
 334        }
 335        catch (Exception ex)
 336        {
 337            _logger.LogError(ex, "Exception during bet placement");
 338            return false;
 339        }
 340    }
 341
 342    /// <inheritdoc />
 343    public async Task<bool> PlaceBetsAsync(string community, Dictionary<Match, BetPrediction> bets, bool overrideBets = 
 344    {
 345        try
 346        {
 347            var url = $"{community}/tippabgabe";
 348            var response = await _httpClient.GetAsync(url);
 349
 350            if (!response.IsSuccessStatusCode)
 351            {
 352                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 353                return false;
 354            }
 355
 356            var pageContent = await response.Content.ReadAsStringAsync();
 357            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 358
 359            // Find the bet form
 360            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 361            if (betForm == null)
 362            {
 363                _logger.LogWarning("Could not find betting form on the page");
 364                return false;
 365            }
 366
 367            // Find the main content area
 368            var contentArea = document.QuerySelector("#kicktipp-content");
 369            if (contentArea == null)
 370            {
 371                _logger.LogWarning("Could not find content area on the betting page");
 372                return false;
 373            }
 374
 375            // Find the table with predictions
 376            var tbody = contentArea.QuerySelector("tbody");
 377            if (tbody == null)
 378            {
 379                _logger.LogWarning("No betting table found");
 380                return false;
 381            }
 382
 383            var rows = tbody.QuerySelectorAll("tr");
 384            var formData = new List<KeyValuePair<string, string>>();
 385            var betsPlaced = 0;
 386            var betsSkipped = 0;
 387
 388            // Add hidden fields from the form
 389            var hiddenInputs = betForm.QuerySelectorAll("input[type=hidden]").OfType<IHtmlInputElement>();
 390            foreach (var input in hiddenInputs)
 391            {
 392                if (!string.IsNullOrEmpty(input.Name) && input.Value != null)
 393                {
 394                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 395                }
 396            }
 397
 398            // Process all matches in the form
 399            foreach (var row in rows)
 400            {
 401                var cells = row.QuerySelectorAll("td");
 402                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 403
 404                try
 405                {
 406                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 407                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 408
 409                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 410                        continue;
 411
 412                    // Check if we have a bet for this match
 413                    var matchKey = bets.Keys.FirstOrDefault(m => m.HomeTeam == homeTeam && m.AwayTeam == roadTeam);
 414                    if (matchKey == null)
 415                    {
 416                        // Add existing bet values to maintain form state
 417                        var existingHomeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 418                        var existingAwayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 419
 420                        if (existingHomeInput != null && existingAwayInput != null &&
 421                            !string.IsNullOrEmpty(existingHomeInput.Name) && !string.IsNullOrEmpty(existingAwayInput.Nam
 422                        {
 423                            formData.Add(new KeyValuePair<string, string>(existingHomeInput.Name, existingHomeInput.Valu
 424                            formData.Add(new KeyValuePair<string, string>(existingAwayInput.Name, existingAwayInput.Valu
 425                        }
 426                        continue;
 427                    }
 428
 429                    var prediction = bets[matchKey];
 430
 431                    // Find bet input fields in the row
 432                    var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 433                    var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 434
 435                    if (homeInput == null || awayInput == null)
 436                    {
 437                        _logger.LogWarning("No betting inputs found for {MatchKey}, skipping", matchKey);
 438                        continue;
 439                    }
 440
 441                    // Check if bets are already placed
 442                    var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 443                    var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 444
 445                    if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBets)
 446                    {
 447                        var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 448                        _logger.LogInformation("{MatchKey} - skipped, already placed {ExistingBet}", matchKey, existingB
 449                        betsSkipped++;
 450
 451                        // Keep existing values
 452                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 453                        {
 454                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, homeInput.Value ?? ""));
 455                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, awayInput.Value ?? ""));
 456                        }
 457                        continue;
 458                    }
 459
 460                    // Add bet to form data
 461                    if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 462                    {
 463                        formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString()));
 464                        formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString()));
 465                        betsPlaced++;
 466                        _logger.LogInformation("{MatchKey} - betting {Prediction}", matchKey, prediction);
 467                    }
 468                    else
 469                    {
 470                        _logger.LogWarning("{MatchKey} - input field names are missing, skipping", matchKey);
 471                        continue;
 472                    }
 473                }
 474                catch (Exception ex)
 475                {
 476                    _logger.LogError(ex, "Error processing betting row");
 477                    continue;
 478                }
 479            }
 480
 481            _logger.LogInformation("Summary: {BetsPlaced} bets to place, {BetsSkipped} skipped", betsPlaced, betsSkipped
 482
 483            if (betsPlaced == 0)
 484            {
 485                _logger.LogInformation("No bets to place");
 486                return true;
 487            }
 488
 489            // Find submit button
 490            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 491            var submitName = "submitbutton"; // Default from Python
 492
 493            if (submitButton != null)
 494            {
 495                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 496                {
 497                    submitName = inputSubmit.Name;
 498                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 499                }
 500                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 501                {
 502                    submitName = buttonSubmit.Name;
 503                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 504                }
 505            }
 506            else
 507            {
 508                // Fallback to default submit button name
 509                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 510            }
 511
 512            // Submit form
 513            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 514                (betForm.Action.StartsWith("http") ? betForm.Action :
 515                 betForm.Action.StartsWith("/") ? betForm.Action :
 516                 $"{community}/{betForm.Action}");
 517
 518            var formContent = new FormUrlEncodedContent(formData);
 519            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 520
 521            if (submitResponse.IsSuccessStatusCode)
 522            {
 523                _logger.LogInformation("✓ Successfully submitted {BetsPlaced} bets!", betsPlaced);
 524                return true;
 525            }
 526            else
 527            {
 528                _logger.LogError("✗ Failed to submit bets. Status: {StatusCode}", submitResponse.StatusCode);
 529                return false;
 530            }
 531        }
 532        catch (Exception ex)
 533        {
 534            _logger.LogError(ex, "Exception during bet placement");
 535            return false;
 536        }
 537    }
 538
 539    /// <inheritdoc />
 540    public async Task<List<TeamStanding>> GetStandingsAsync(string community)
 541    {
 542        // Create cache key based on community
 543        var cacheKey = $"standings_{community}";
 544
 545        // Try to get from cache first
 546        if (_cache.TryGetValue(cacheKey, out List<TeamStanding>? cachedStandings))
 547        {
 548            _logger.LogDebug("Retrieved standings for {Community} from cache", community);
 549            return cachedStandings!;
 550        }
 551
 552        try
 553        {
 554            var url = $"{community}/tabellen";
 555            var response = await _httpClient.GetAsync(url);
 556
 557            if (!response.IsSuccessStatusCode)
 558            {
 559                _logger.LogError("Failed to fetch standings page. Status: {StatusCode}", response.StatusCode);
 560                return new List<TeamStanding>();
 561            }
 562
 563            var content = await response.Content.ReadAsStringAsync();
 564            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 565
 566            var standings = new List<TeamStanding>();
 567
 568            // Tournament pages can render one table per group; league pages render a single table.
 569            var standingsTables = document.QuerySelectorAll("table.sporttabelle");
 570            if (standingsTables.Length == 0)
 571            {
 572                _logger.LogWarning("Could not find standings table");
 573                return standings;
 574            }
 575
 576            foreach (var standingsTable in standingsTables)
 577            {
 578                var groupName = ExtractStandingsGroupName(standingsTable);
 579                var tableBody = standingsTable.QuerySelector("tbody") ?? standingsTable;
 580                var rows = tableBody.QuerySelectorAll("tr");
 581                _logger.LogDebug("Found {RowCount} team rows in standings table for group {Group}", rows.Length, groupNa
 582
 583                foreach (var row in rows)
 584                {
 585                    try
 586                    {
 587                        var cells = row.QuerySelectorAll("td");
 588                        if (cells.Length >= 9) // Need at least 9 columns for all data
 589                        {
 590                            // Extract data from table cells
 591                            var positionText = cells[0].TextContent?.Trim().TrimEnd('.') ?? "";
 592                            var teamNameElement = cells[1].QuerySelector("div") ?? cells[1].QuerySelector("a");
 593                            var teamName = teamNameElement?.TextContent?.Trim() ?? cells[1].TextContent?.Trim() ?? "";
 594                            var gamesPlayedText = cells[2].TextContent?.Trim() ?? "";
 595                            var pointsText = cells[3].TextContent?.Trim() ?? "";
 596                            var goalsText = cells[4].TextContent?.Trim() ?? "";
 597                            var goalDifferenceText = cells[5].TextContent?.Trim() ?? "";
 598                            var winsText = cells[6].TextContent?.Trim() ?? "";
 599                            var drawsText = cells[7].TextContent?.Trim() ?? "";
 600                            var lossesText = cells[8].TextContent?.Trim() ?? "";
 601
 602                            // Parse numeric values
 603                            if (int.TryParse(positionText, out var position) &&
 604                                int.TryParse(gamesPlayedText, out var gamesPlayed) &&
 605                                int.TryParse(pointsText, out var points) &&
 606                                int.TryParse(goalDifferenceText, out var goalDifference) &&
 607                                int.TryParse(winsText, out var wins) &&
 608                                int.TryParse(drawsText, out var draws) &&
 609                                int.TryParse(lossesText, out var losses))
 610                            {
 611                                // Parse goals (format: "15:8")
 612                                var goalsParts = goalsText.Split(':');
 613                                var goalsFor = 0;
 614                                var goalsAgainst = 0;
 615
 616                                if (goalsParts.Length == 2)
 617                                {
 618                                    int.TryParse(goalsParts[0], out goalsFor);
 619                                    int.TryParse(goalsParts[1], out goalsAgainst);
 620                                }
 621
 622                                var teamStanding = new TeamStanding(
 623                                    position,
 624                                    teamName,
 625                                    gamesPlayed,
 626                                    points,
 627                                    goalsFor,
 628                                    goalsAgainst,
 629                                    goalDifference,
 630                                    wins,
 631                                    draws,
 632                                    losses,
 633                                    groupName);
 634
 635                                standings.Add(teamStanding);
 636                                _logger.LogDebug(
 637                                    "Parsed team standing: {Position}. {TeamName} - {Points} points (group {Group})",
 638                                    position,
 639                                    teamName,
 640                                    points,
 641                                    groupName ?? "(none)");
 642                            }
 643                            else
 644                            {
 645                                _logger.LogWarning("Failed to parse numeric values for team row");
 646                            }
 647                        }
 648                    }
 649                    catch (Exception ex)
 650                    {
 651                        _logger.LogWarning(ex, "Error parsing standings row");
 652                        continue;
 653                    }
 654                }
 655            }
 656
 657            _logger.LogInformation("Successfully parsed {StandingsCount} team standings", standings.Count);
 658
 659            // Cache the results for 20 minutes (standings change relatively infrequently)
 660            var cacheOptions = new MemoryCacheEntryOptions
 661            {
 662                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20),
 663                SlidingExpiration = TimeSpan.FromMinutes(10) // Reset timer if accessed within 10 minutes
 664            };
 665            _cache.Set(cacheKey, standings, cacheOptions);
 666            _logger.LogDebug("Cached standings for {Community} for 20 minutes", community);
 667
 668            return standings;
 669        }
 670        catch (Exception ex)
 671        {
 672            _logger.LogError(ex, "Exception in GetStandingsAsync");
 673            return new List<TeamStanding>();
 674        }
 675    }
 676
 677    /// <inheritdoc />
 678    public Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community)
 679    {
 680        return GetMatchesWithHistoryAsync(community, null);
 681    }
 682
 683    /// <inheritdoc />
 684    public Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community, int matchday)
 685    {
 686        return GetMatchesWithHistoryAsync(community, (int?)matchday);
 687    }
 688
 689    private async Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community, int? matchday)
 690    {
 691        // Create cache key based on community
 692        var cacheKey = matchday.HasValue
 693            ? $"matches_history_{community}_{matchday.Value}"
 694            : $"matches_history_{community}";
 695
 696        // Try to get from cache first
 697        if (_cache.TryGetValue(cacheKey, out List<MatchWithHistory>? cachedMatches))
 698        {
 699            _logger.LogDebug("Retrieved matches with history for {Community} from cache", community);
 700            return cachedMatches!;
 701        }
 702
 703        try
 704        {
 705            var matches = new List<MatchWithHistory>();
 706
 707            // First, get the tippabgabe page to find the link to spielinfos
 708            var tippabgabeUrl = matchday.HasValue
 709                ? $"{community}/tippabgabe?spieltagIndex={matchday.Value}"
 710                : $"{community}/tippabgabe";
 711            var response = await _httpClient.GetAsync(tippabgabeUrl);
 712
 713            if (!response.IsSuccessStatusCode)
 714            {
 715                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 716                return matches;
 717            }
 718
 719            var content = await response.Content.ReadAsStringAsync();
 720            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 721
 722            // Extract matchday from the tippabgabe page
 723            var currentMatchday = ExtractMatchdayFromPage(document);
 724            _logger.LogDebug("Extracted matchday for history extraction: {Matchday}", currentMatchday);
 725            if (matchday.HasValue && currentMatchday != matchday.Value)
 726            {
 727                _logger.LogWarning("Requested history matchday {RequestedMatchday}, but page displayed {DisplayedMatchda
 728            }
 729
 730            // Find the "Tippabgabe mit Spielinfos" link
 731            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 732            if (spielinfoLink == null)
 733            {
 734                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 735                return matches;
 736            }
 737
 738            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 739            if (string.IsNullOrEmpty(spielinfoUrl))
 740            {
 741                _logger.LogWarning("Spielinfo link has no href attribute");
 742                return matches;
 743            }
 744
 745            // Make URL absolute if it's relative
 746            if (spielinfoUrl.StartsWith("/"))
 747            {
 748                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 749            }
 750
 751            _logger.LogInformation("Starting to fetch match details from spielinfo pages...");
 752
 753            // Navigate through all matches using the right arrow navigation
 754            var currentUrl = spielinfoUrl;
 755            var matchCount = 0;
 756
 757            while (!string.IsNullOrEmpty(currentUrl))
 758            {
 759                try
 760                {
 761                    var spielinfoResponse = await _httpClient.GetAsync(currentUrl);
 762                    if (!spielinfoResponse.IsSuccessStatusCode)
 763                    {
 764                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", currentUrl, sp
 765                        break;
 766                    }
 767
 768                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 769                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 770
 771                    // Extract match information
 772                    var matchWithHistory = ExtractMatchWithHistoryFromSpielinfoPage(spielinfoDocument, currentMatchday);
 773                    if (matchWithHistory != null)
 774                    {
 775                        matches.Add(matchWithHistory);
 776                        matchCount++;
 777                        _logger.LogDebug("Extracted match {Count}: {Match}", matchCount, matchWithHistory.Match);
 778                    }
 779
 780                    // Find the next match link (right arrow)
 781                    var nextLink = FindNextMatchLink(spielinfoDocument);
 782                    if (nextLink != null)
 783                    {
 784                        currentUrl = nextLink;
 785                        if (currentUrl.StartsWith("/"))
 786                        {
 787                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 788                        }
 789                    }
 790                    else
 791                    {
 792                        // No more matches
 793                        break;
 794                    }
 795                }
 796                catch (Exception ex)
 797                {
 798                    _logger.LogError(ex, "Error processing spielinfo page: {Url}", currentUrl);
 799                    break;
 800                }
 801            }
 802
 803            _logger.LogInformation("Successfully extracted {MatchCount} matches with history", matches.Count);
 804
 805            // Cache the results for 15 minutes (match info changes less frequently than live scores)
 806            var cacheOptions = new MemoryCacheEntryOptions
 807            {
 808                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
 809                SlidingExpiration = TimeSpan.FromMinutes(7) // Reset timer if accessed within 7 minutes
 810            };
 811            _cache.Set(cacheKey, matches, cacheOptions);
 812            _logger.LogDebug("Cached matches with history for {Community} for 15 minutes", community);
 813
 814            return matches;
 815        }
 816        catch (Exception ex)
 817        {
 818            _logger.LogError(ex, "Exception in GetMatchesWithHistoryAsync");
 819            return new List<MatchWithHistory>();
 820        }
 821    }
 822
 823    /// <inheritdoc />
 824    public async Task<int> GetCurrentTippuebersichtMatchdayAsync(string community)
 825    {
 826        var document = await GetTippuebersichtDocumentAsync(community, null);
 827        if (document == null)
 828        {
 829            return 1;
 830        }
 831
 832        return ExtractMatchdayFromPage(document);
 833    }
 834
 835    /// <inheritdoc />
 836    public async Task<IReadOnlyList<CollectedMatchOutcome>> GetMatchdayOutcomesAsync(string community, int matchday)
 837    {
 838        var cacheKey = $"tippuebersicht_outcomes_{community}_{matchday}";
 839        if (_cache.TryGetValue(cacheKey, out IReadOnlyList<CollectedMatchOutcome>? cachedOutcomes))
 840        {
 841            _logger.LogDebug("Retrieved tippuebersicht outcomes for {Community} matchday {Matchday} from cache", communi
 842            return cachedOutcomes!;
 843        }
 844
 845        var document = await GetTippuebersichtDocumentAsync(community, matchday);
 846        if (document == null)
 847        {
 848            return Array.Empty<CollectedMatchOutcome>();
 849        }
 850
 851        var displayedMatchday = ExtractMatchdayFromPage(document);
 852        if (displayedMatchday != matchday)
 853        {
 854            _logger.LogWarning("Requested tippuebersicht matchday {RequestedMatchday}, but page displayed {DisplayedMatc
 855        }
 856
 857        var outcomes = ParseTippuebersichtMatchdayOutcomes(document, displayedMatchday)
 858            .AsReadOnly();
 859
 860        var cacheOptions = new MemoryCacheEntryOptions
 861        {
 862            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
 863            SlidingExpiration = TimeSpan.FromMinutes(5)
 864        };
 865
 866        _cache.Set(cacheKey, outcomes, cacheOptions);
 867        return outcomes;
 868    }
 869
 870    /// <inheritdoc />
 871    public async Task<KicktippCommunityMatchdaySnapshot?> GetCommunityMatchdaySnapshotAsync(string community, int matchd
 872    {
 873        var cacheKey = $"tippuebersicht_snapshot_{community}_{matchday}";
 874        if (_cache.TryGetValue(cacheKey, out KicktippCommunityMatchdaySnapshot? cachedSnapshot))
 875        {
 876            _logger.LogDebug("Retrieved tippuebersicht snapshot for {Community} matchday {Matchday} from cache", communi
 877            return cachedSnapshot;
 878        }
 879
 880        var document = await GetTippuebersichtDocumentAsync(community, matchday);
 881        if (document == null)
 882        {
 883            return null;
 884        }
 885
 886        var displayedMatchday = ExtractMatchdayFromPage(document);
 887        if (displayedMatchday != matchday)
 888        {
 889            _logger.LogWarning("Requested tippuebersicht snapshot matchday {RequestedMatchday}, but page displayed {Disp
 890        }
 891
 892        var outcomes = ParseTippuebersichtMatchdayOutcomes(document, displayedMatchday)
 893            .AsReadOnly();
 894        var participants = ParseTippuebersichtParticipantSnapshots(document, displayedMatchday, outcomes)
 895            .AsReadOnly();
 896
 897        var snapshot = new KicktippCommunityMatchdaySnapshot(displayedMatchday, outcomes, participants);
 898        var cacheOptions = new MemoryCacheEntryOptions
 899        {
 900            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
 901            SlidingExpiration = TimeSpan.FromMinutes(5)
 902        };
 903
 904        _cache.Set(cacheKey, snapshot, cacheOptions);
 905        return snapshot;
 906    }
 907
 908    /// <inheritdoc />
 909    public async Task<(List<MatchResult> homeTeamHomeHistory, List<MatchResult> awayTeamAwayHistory)> GetHomeAwayHistory
 910    {
 911        try
 912        {
 913            // First, get the tippabgabe page to find the link to spielinfos
 914            var tippabgabeUrl = $"{community}/tippabgabe";
 915            var response = await _httpClient.GetAsync(tippabgabeUrl);
 916
 917            if (!response.IsSuccessStatusCode)
 918            {
 919                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 920                return (new List<MatchResult>(), new List<MatchResult>());
 921            }
 922
 923            var content = await response.Content.ReadAsStringAsync();
 924            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 925
 926            // Find the "Tippabgabe mit Spielinfos" link
 927            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 928            if (spielinfoLink == null)
 929            {
 930                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 931                return (new List<MatchResult>(), new List<MatchResult>());
 932            }
 933
 934            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 935            if (string.IsNullOrEmpty(spielinfoUrl))
 936            {
 937                _logger.LogWarning("Spielinfo link has no href attribute");
 938                return (new List<MatchResult>(), new List<MatchResult>());
 939            }
 940
 941            // Make URL absolute if it's relative
 942            if (spielinfoUrl.StartsWith("/"))
 943            {
 944                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 945            }
 946
 947            // Navigate through all matches using the right arrow navigation
 948            var currentUrl = spielinfoUrl;
 949
 950            while (!string.IsNullOrEmpty(currentUrl))
 951            {
 952                try
 953                {
 954                    // Add ansicht=2 parameter for home/away history
 955                    var homeAwayUrl = currentUrl.Contains('?')
 956                        ? $"{currentUrl}&ansicht=2"
 957                        : $"{currentUrl}?ansicht=2";
 958
 959                    var spielinfoResponse = await _httpClient.GetAsync(homeAwayUrl);
 960                    if (!spielinfoResponse.IsSuccessStatusCode)
 961                    {
 962                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", homeAwayUrl, s
 963                        break;
 964                    }
 965
 966                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 967                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 968
 969                    // Check if this page contains our match
 970                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 971                    {
 972                        // Extract home team home history
 973                        var homeTeamHomeHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoHeim");
 974
 975                        // Extract away team away history
 976                        var awayTeamAwayHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoGast");
 977
 978                        return (homeTeamHomeHistory, awayTeamAwayHistory);
 979                    }
 980
 981                    // Find the next match link (right arrow)
 982                    var nextLink = FindNextMatchLink(spielinfoDocument);
 983                    if (nextLink != null)
 984                    {
 985                        currentUrl = nextLink;
 986                        if (currentUrl.StartsWith("/"))
 987                        {
 988                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 989                        }
 990                    }
 991                    else
 992                    {
 993                        // No more matches
 994                        break;
 995                    }
 996                }
 997                catch (Exception ex)
 998                {
 999                    _logger.LogError(ex, "Error processing spielinfo page for home/away history: {CurrentUrl}", currentU
 1000                    break;
 1001                }
 1002            }
 1003
 1004            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 1005            return (new List<MatchResult>(), new List<MatchResult>());
 1006        }
 1007        catch (Exception ex)
 1008        {
 1009            _logger.LogError(ex, "Exception in GetHomeAwayHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 1010            return (new List<MatchResult>(), new List<MatchResult>());
 1011        }
 1012    }
 1013
 1014    /// <inheritdoc />
 1015    public async Task<List<MatchResult>> GetHeadToHeadHistoryAsync(string community, string homeTeam, string awayTeam)
 1016    {
 1017        try
 1018        {
 1019            // First, get the tippabgabe page to find the link to spielinfos
 1020            var tippabgabeUrl = $"{community}/tippabgabe";
 1021            var response = await _httpClient.GetAsync(tippabgabeUrl);
 1022
 1023            if (!response.IsSuccessStatusCode)
 1024            {
 1025                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1026                return new List<MatchResult>();
 1027            }
 1028
 1029            var content = await response.Content.ReadAsStringAsync();
 1030            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1031
 1032            // Find the "Tippabgabe mit Spielinfos" link
 1033            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1034            if (spielinfoLink == null)
 1035            {
 1036                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1037                return new List<MatchResult>();
 1038            }
 1039
 1040            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1041            if (string.IsNullOrEmpty(spielinfoUrl))
 1042            {
 1043                _logger.LogWarning("Spielinfo link has no href attribute");
 1044                return new List<MatchResult>();
 1045            }
 1046
 1047            // Make URL absolute if it's relative
 1048            if (spielinfoUrl.StartsWith("/"))
 1049            {
 1050                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 1051            }
 1052
 1053            // Navigate through all matches using the right arrow navigation
 1054            var currentUrl = spielinfoUrl;
 1055
 1056            while (!string.IsNullOrEmpty(currentUrl))
 1057            {
 1058                try
 1059                {
 1060                    // Add ansicht=3 parameter for head-to-head history
 1061                    var headToHeadUrl = currentUrl.Contains('?')
 1062                        ? $"{currentUrl}&ansicht=3"
 1063                        : $"{currentUrl}?ansicht=3";
 1064
 1065                    var spielinfoResponse = await _httpClient.GetAsync(headToHeadUrl);
 1066                    if (!spielinfoResponse.IsSuccessStatusCode)
 1067                    {
 1068                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", headToHeadUrl,
 1069                        break;
 1070                    }
 1071
 1072                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1073                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1074
 1075                    // Check if this page contains our match
 1076                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1077                    {
 1078                        // Extract head-to-head history
 1079                        return ExtractTeamHistory(spielinfoDocument, "spielinfoDirekterVergleich");
 1080                    }
 1081
 1082                    // Find the next match link (right arrow)
 1083                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1084                    if (nextLink != null)
 1085                    {
 1086                        currentUrl = nextLink;
 1087                        if (currentUrl.StartsWith("/"))
 1088                        {
 1089                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1090                        }
 1091                    }
 1092                    else
 1093                    {
 1094                        // No more matches
 1095                        break;
 1096                    }
 1097                }
 1098                catch (Exception ex)
 1099                {
 1100                    _logger.LogError(ex, "Error processing spielinfo page for head-to-head history: {CurrentUrl}", curre
 1101                    break;
 1102                }
 1103            }
 1104
 1105            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 1106            return new List<MatchResult>();
 1107        }
 1108        catch (Exception ex)
 1109        {
 1110            _logger.LogError(ex, "Exception in GetHeadToHeadHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTe
 1111            return new List<MatchResult>();
 1112        }
 1113    }
 1114
 1115    /// <inheritdoc />
 1116    public async Task<List<HeadToHeadResult>> GetHeadToHeadDetailedHistoryAsync(string community, string homeTeam, strin
 1117    {
 1118        try
 1119        {
 1120            // First, get the tippabgabe page to find the link to spielinfos
 1121            var tippabgabeUrl = $"{community}/tippabgabe";
 1122            var response = await _httpClient.GetAsync(tippabgabeUrl);
 1123
 1124            if (!response.IsSuccessStatusCode)
 1125            {
 1126                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1127                return new List<HeadToHeadResult>();
 1128            }
 1129
 1130            var content = await response.Content.ReadAsStringAsync();
 1131            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1132
 1133            // Find the "Tippabgabe mit Spielinfos" link
 1134            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1135            if (spielinfoLink == null)
 1136            {
 1137                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1138                return new List<HeadToHeadResult>();
 1139            }
 1140
 1141            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1142            if (string.IsNullOrEmpty(spielinfoUrl))
 1143            {
 1144                _logger.LogWarning("Spielinfo link has no href attribute");
 1145                return new List<HeadToHeadResult>();
 1146            }
 1147
 1148            // Make URL absolute if it's relative
 1149            if (spielinfoUrl.StartsWith("/"))
 1150            {
 1151                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 1152            }
 1153
 1154            // Navigate through all matches using the right arrow navigation
 1155            var currentUrl = spielinfoUrl;
 1156
 1157            while (!string.IsNullOrEmpty(currentUrl))
 1158            {
 1159                try
 1160                {
 1161                    // Append ansicht=3 to get head-to-head view
 1162                    var urlWithAnsicht = currentUrl.Contains('?') ? $"{currentUrl}&ansicht=3" : $"{currentUrl}?ansicht=3
 1163                    var spielinfoResponse = await _httpClient.GetAsync(urlWithAnsicht);
 1164
 1165                    if (!spielinfoResponse.IsSuccessStatusCode)
 1166                    {
 1167                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", urlWithAnsicht
 1168                        break;
 1169                    }
 1170
 1171                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1172                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1173
 1174                    // Check if this page contains our match
 1175                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1176                    {
 1177                        // Extract head-to-head history from this page
 1178                        return ExtractHeadToHeadHistory(spielinfoDocument);
 1179                    }
 1180
 1181                    // Find the next match link (right arrow)
 1182                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1183                    if (nextLink != null)
 1184                    {
 1185                        currentUrl = nextLink;
 1186                        if (currentUrl.StartsWith("/"))
 1187                        {
 1188                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1189                        }
 1190                    }
 1191                    else
 1192                    {
 1193                        break;
 1194                    }
 1195                }
 1196                catch (Exception ex)
 1197                {
 1198                    _logger.LogWarning(ex, "Error processing spielinfo page: {Url}", currentUrl);
 1199                    break;
 1200                }
 1201            }
 1202
 1203            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 1204            return new List<HeadToHeadResult>();
 1205        }
 1206        catch (Exception ex)
 1207        {
 1208            _logger.LogError(ex, "Exception in GetHeadToHeadDetailedHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam
 1209            return new List<HeadToHeadResult>();
 1210        }
 1211    }
 1212    private bool IsMatchOnPage(IDocument document, string homeTeam, string awayTeam)
 1213    {
 1214        try
 1215        {
 1216            // Look for the match in the tippabgabe table
 1217            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 1218
 1219            foreach (var row in matchRows)
 1220            {
 1221                var cells = row.QuerySelectorAll("td");
 1222                if (cells.Length >= 3)
 1223                {
 1224                    var pageHomeTeam = cells[1].TextContent?.Trim() ?? "";
 1225                    var pageAwayTeam = cells[2].TextContent?.Trim() ?? "";
 1226
 1227                    if (pageHomeTeam == homeTeam && pageAwayTeam == awayTeam)
 1228                    {
 1229                        return true;
 1230                    }
 1231                }
 1232            }
 1233
 1234            return false;
 1235        }
 1236        catch (Exception ex)
 1237        {
 1238            _logger.LogDebug(ex, "Error checking if match is on page");
 1239            return false;
 1240        }
 1241    }
 1242
 1243    private MatchWithHistory? ExtractMatchWithHistoryFromSpielinfoPage(IDocument document, int matchday)
 1244    {
 1245        try
 1246        {
 1247            // Extract match information from the tippabgabe table
 1248            // Look for all rows in the table, not just the first one
 1249            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 1250            if (matchRows.Length == 0)
 1251            {
 1252                _logger.LogWarning("Could not find any match rows in tippabgabe table on spielinfo page");
 1253                return null;
 1254            }
 1255
 1256            _logger.LogDebug("Found {RowCount} rows in tippabgabe table", matchRows.Length);
 1257
 1258            // Find the row that contains match data (has input fields for betting)
 1259            IElement? matchRow = null;
 1260            foreach (var row in matchRows)
 1261            {
 1262                var rowCells = row.QuerySelectorAll("td");
 1263                if (rowCells.Length >= 4)
 1264                {
 1265                    // Check if this row has betting inputs (indicates it's the match row)
 1266                    var bettingInputs = rowCells[3].QuerySelectorAll("input[type='text']");
 1267                    if (bettingInputs.Length >= 2)
 1268                    {
 1269                        matchRow = row;
 1270                        break;
 1271                    }
 1272                }
 1273            }
 1274
 1275            if (matchRow == null)
 1276            {
 1277                _logger.LogWarning("Could not find match row with betting inputs in tippabgabe table");
 1278                return null;
 1279            }
 1280
 1281            var cells = matchRow.QuerySelectorAll("td");
 1282            if (cells.Length < 4)
 1283            {
 1284                _logger.LogWarning("Match row does not have enough cells");
 1285                return null;
 1286            }
 1287
 1288            _logger.LogDebug("Found {CellCount} cells in match row", cells.Length);
 1289            for (int i = 0; i < Math.Min(cells.Length, 5); i++)
 1290            {
 1291                _logger.LogDebug("Cell[{Index}]: '{Content}' (Class: '{Class}')", i, cells[i].TextContent?.Trim(), cells
 1292            }
 1293
 1294            var timeText = cells[0].TextContent?.Trim() ?? "";
 1295            var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1296            var awayTeam = cells[2].TextContent?.Trim() ?? "";
 1297
 1298            _logger.LogDebug("Extracted from spielinfo page - Time: '{TimeText}', Home: '{HomeTeam}', Away: '{AwayTeam}'
 1299
 1300            if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(awayTeam))
 1301            {
 1302                _logger.LogWarning("Could not extract team names from match table");
 1303                return null;
 1304            }
 1305
 1306            // Check if match is cancelled ("Abgesagt" in German)
 1307            // Note: On spielinfo pages, cancelled matches may still show - process them with IsCancelled flag
 1308            var isCancelled = IsCancelledTimeText(timeText);
 1309            if (isCancelled)
 1310            {
 1311                _logger.LogWarning(
 1312                    "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt) on spielinfo page. " +
 1313                    "Using current time as fallback since spielinfo doesn't provide time inheritance context.",
 1314                    homeTeam, awayTeam);
 1315            }
 1316
 1317            var startsAt = ParseMatchDateTime(timeText);
 1318            var match = new Match(homeTeam, awayTeam, startsAt, matchday, isCancelled);
 1319
 1320            // Extract home team history
 1321            var homeTeamHistory = ExtractTeamHistory(document, "spielinfoHeim");
 1322
 1323            // Extract away team history
 1324            var awayTeamHistory = ExtractTeamHistory(document, "spielinfoGast");
 1325
 1326            return new MatchWithHistory(match, homeTeamHistory, awayTeamHistory);
 1327        }
 1328        catch (Exception ex)
 1329        {
 1330            _logger.LogError(ex, "Error extracting match with history from spielinfo page");
 1331            return null;
 1332        }
 1333    }
 1334
 1335    private List<MatchResult> ExtractTeamHistory(IDocument document, string tableClass)
 1336    {
 1337        var results = new List<MatchResult>();
 1338
 1339        try
 1340        {
 1341            var table = document.QuerySelector($"table.{tableClass} tbody");
 1342            if (table == null)
 1343            {
 1344                _logger.LogDebug("Could not find team history table with class: {TableClass}", tableClass);
 1345                return results;
 1346            }
 1347
 1348            var rows = table.QuerySelectorAll("tr");
 1349            foreach (var row in rows)
 1350            {
 1351                try
 1352                {
 1353                    var cells = row.QuerySelectorAll("td");
 1354
 1355                    // Handle different table formats
 1356                    string competition, homeTeam, awayTeam;
 1357                    var resultCell = cells.Last(); // Result is always in the last cell
 1358                    var homeGoals = (int?)null;
 1359                    var awayGoals = (int?)null;
 1360                    var outcome = MatchOutcome.Pending;
 1361                    string? annotation = null;
 1362
 1363                    if (tableClass == "spielinfoDirekterVergleich")
 1364                    {
 1365                        // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 1366                        if (cells.Length < 6)
 1367                            continue;
 1368
 1369                        competition = $"{cells[0].TextContent?.Trim()} {cells[1].TextContent?.Trim()}";
 1370                        homeTeam = cells[3].TextContent?.Trim() ?? "";
 1371                        awayTeam = cells[4].TextContent?.Trim() ?? "";
 1372                    }
 1373                    else
 1374                    {
 1375                        // Standard format: Competition | Home | Away | Result
 1376                        if (cells.Length < 4)
 1377                            continue;
 1378
 1379                        competition = cells[0].TextContent?.Trim() ?? "";
 1380                        homeTeam = cells[1].TextContent?.Trim() ?? "";
 1381                        awayTeam = cells[2].TextContent?.Trim() ?? "";
 1382                    }
 1383
 1384                    // Parse the score from the result cell
 1385                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 1386                    if (scoreElements.Length >= 2)
 1387                    {
 1388                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 1389                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1390
 1391                        if (homeScoreText != "-" && awayScoreText != "-")
 1392                        {
 1393                            if (int.TryParse(homeScoreText, out var homeScore) && int.TryParse(awayScoreText, out var aw
 1394                            {
 1395                                homeGoals = homeScore;
 1396                                awayGoals = awayScore;
 1397
 1398                                // Determine outcome from team's perspective based on CSS classes
 1399                                var homeTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[3] : cells[1];
 1400                                var awayTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[4] : cells[2];
 1401
 1402                                var isHomeTeam = homeTeamCell.ClassList.Contains("sieg") || homeTeamCell.ClassList.Conta
 1403                                var isAwayTeam = awayTeamCell.ClassList.Contains("sieg") || awayTeamCell.ClassList.Conta
 1404
 1405                                if (isHomeTeam)
 1406                                {
 1407                                    outcome = homeScore > awayScore ? MatchOutcome.Win :
 1408                                             homeScore < awayScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1409                                }
 1410                                else if (isAwayTeam)
 1411                                {
 1412                                    outcome = awayScore > homeScore ? MatchOutcome.Win :
 1413                                             awayScore < homeScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1414                                }
 1415                                else
 1416                                {
 1417                                    // Fallback: determine from score (neutral perspective)
 1418                                    outcome = homeScore == awayScore ? MatchOutcome.Draw :
 1419                                             homeScore > awayScore ? MatchOutcome.Win : MatchOutcome.Loss;
 1420                                }
 1421                            }
 1422                        }
 1423                    }
 1424
 1425                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 1426                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 1427                    if (annotationElement != null)
 1428                    {
 1429                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1430                    }
 1431
 1432                    var matchResult = new MatchResult(competition, homeTeam, awayTeam, homeGoals, awayGoals, outcome, an
 1433                    results.Add(matchResult);
 1434                }
 1435                catch (Exception ex)
 1436                {
 1437                    _logger.LogDebug(ex, "Error parsing team history row");
 1438                    continue;
 1439                }
 1440            }
 1441        }
 1442        catch (Exception ex)
 1443        {
 1444            _logger.LogError(ex, "Error extracting team history for table class: {TableClass}", tableClass);
 1445        }
 1446
 1447        return results;
 1448    }
 1449
 1450    private List<HeadToHeadResult> ExtractHeadToHeadHistory(IDocument document)
 1451    {
 1452        var results = new List<HeadToHeadResult>();
 1453
 1454        try
 1455        {
 1456            var table = document.QuerySelector("table.spielinfoDirekterVergleich tbody");
 1457            if (table == null)
 1458            {
 1459                _logger.LogDebug("Could not find head-to-head table with class: spielinfoDirekterVergleich");
 1460                return results;
 1461            }
 1462
 1463            var rows = table.QuerySelectorAll("tr");
 1464            foreach (var row in rows)
 1465            {
 1466                try
 1467                {
 1468                    var cells = row.QuerySelectorAll("td");
 1469
 1470                    // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 1471                    if (cells.Length < 6)
 1472                        continue;
 1473
 1474                    var league = cells[0].TextContent?.Trim() ?? "";
 1475                    var matchday = cells[1].TextContent?.Trim() ?? "";
 1476                    var playedAt = cells[2].TextContent?.Trim() ?? "";
 1477                    var homeTeam = cells[3].TextContent?.Trim() ?? "";
 1478                    var awayTeam = cells[4].TextContent?.Trim() ?? "";
 1479
 1480                    // Extract score from the result cell
 1481                    var resultCell = cells[5];
 1482                    var score = "";
 1483                    string? annotation = null;
 1484
 1485                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 1486                    if (scoreElements.Length >= 2)
 1487                    {
 1488                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 1489                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1490
 1491                        if (homeScoreText != "-" && awayScoreText != "-")
 1492                        {
 1493                            score = $"{homeScoreText}:{awayScoreText}";
 1494                        }
 1495                    }
 1496
 1497                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 1498                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 1499                    if (annotationElement != null)
 1500                    {
 1501                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1502                    }
 1503
 1504                    var headToHeadResult = new HeadToHeadResult(league, matchday, playedAt, homeTeam, awayTeam, score, a
 1505                    results.Add(headToHeadResult);
 1506                }
 1507                catch (Exception ex)
 1508                {
 1509                    _logger.LogDebug(ex, "Error parsing head-to-head row");
 1510                    continue;
 1511                }
 1512            }
 1513        }
 1514        catch (Exception ex)
 1515        {
 1516            _logger.LogError(ex, "Error extracting head-to-head history");
 1517        }
 1518
 1519        return results;
 1520    }
 1521
 1522    private string? FindNextMatchLink(IDocument document)
 1523    {
 1524        try
 1525        {
 1526            // Look for the right arrow button in the match navigation
 1527            var nextButton = document.QuerySelector(".prevnextNext a");
 1528            if (nextButton == null)
 1529            {
 1530                _logger.LogDebug("No next match button found");
 1531                return null;
 1532            }
 1533
 1534            // Check if the button is disabled
 1535            var parentDiv = nextButton.ParentElement;
 1536            if (parentDiv?.ClassList.Contains("disabled") == true)
 1537            {
 1538                _logger.LogDebug("Next match button is disabled - reached end of matches");
 1539                return null;
 1540            }
 1541
 1542            var href = nextButton.GetAttribute("href");
 1543            if (string.IsNullOrEmpty(href))
 1544            {
 1545                _logger.LogDebug("Next match button has no href");
 1546                return null;
 1547            }
 1548
 1549            _logger.LogDebug("Found next match link: {Href}", href);
 1550            return href;
 1551        }
 1552        catch (Exception ex)
 1553        {
 1554            _logger.LogError(ex, "Error finding next match link");
 1555            return null;
 1556        }
 1557    }
 1558
 1559    private ZonedDateTime ParseMatchDateTime(string timeText)
 1560    {
 1561        try
 1562        {
 1563            // Handle empty or null time text
 1564            // Use MinValue to ensure database key consistency and prevent orphaned predictions
 1565            // See docs/features/cancelled-matches.md for design rationale
 1566            if (string.IsNullOrWhiteSpace(timeText))
 1567            {
 1568                _logger.LogWarning("Match time text is empty, using MinValue for database consistency");
 1569                return DateTimeOffset.MinValue.ToZonedDateTime();
 1570            }
 1571
 1572            // Expected formats: "22.08.25 20:30" and "22.08.2026 20:30".
 1573            _logger.LogDebug("Attempting to parse time: '{TimeText}'", timeText);
 1574            var formats = new[] { "dd.MM.yy HH:mm", "dd.MM.yyyy HH:mm" };
 1575            if (DateTime.TryParseExact(timeText, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dat
 1576            {
 1577                _logger.LogDebug("Successfully parsed time: {DateTime}", dateTime);
 1578                var localDateTime = LocalDateTime.FromDateTime(DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified))
 1579                return BerlinTimeZone.AtLeniently(localDateTime);
 1580            }
 1581
 1582            // Fallback to MinValue if parsing fails - ensures database key consistency
 1583            // and prevents orphaned predictions from being created with varying timestamps
 1584            // See docs/features/cancelled-matches.md for design rationale
 1585            _logger.LogWarning("Could not parse match time: '{TimeText}', using MinValue for database consistency", time
 1586            return DateTimeOffset.MinValue.ToZonedDateTime();
 1587        }
 1588        catch (Exception ex)
 1589        {
 1590            _logger.LogError(ex, "Error parsing match time '{TimeText}'", timeText);
 1591            return DateTimeOffset.MinValue.ToZonedDateTime();
 1592        }
 1593    }
 1594
 1595    private static string? ExtractStandingsGroupName(IElement standingsTable)
 1596    {
 1597        var caption = ExtractGroupLabel(standingsTable.QuerySelector("caption")?.TextContent);
 1598        if (!string.IsNullOrWhiteSpace(caption))
 1599        {
 1600            return caption;
 1601        }
 1602
 1603        foreach (var headerCell in standingsTable.QuerySelectorAll("thead th, tr th"))
 1604        {
 1605            var headerLabel = ExtractGroupLabel(headerCell.TextContent);
 1606            if (!string.IsNullOrWhiteSpace(headerLabel))
 1607            {
 1608                return headerLabel;
 1609            }
 1610        }
 1611
 1612        for (var current = standingsTable; current is not null; current = current.ParentElement)
 1613        {
 1614            var labelFromPreviousSibling = ExtractGroupLabelFromPreviousSiblings(current);
 1615            if (!string.IsNullOrWhiteSpace(labelFromPreviousSibling))
 1616            {
 1617                return labelFromPreviousSibling;
 1618            }
 1619
 1620            if (current != standingsTable && ContainsOnlyCurrentStandingsTable(current, standingsTable))
 1621            {
 1622                var labelFromWrapper = ExtractGroupLabel(current.TextContent);
 1623                if (!string.IsNullOrWhiteSpace(labelFromWrapper))
 1624                {
 1625                    return labelFromWrapper;
 1626                }
 1627            }
 1628
 1629            if (current.TagName.Equals("BODY", StringComparison.OrdinalIgnoreCase))
 1630            {
 1631                break;
 1632            }
 1633        }
 1634
 1635        return null;
 1636    }
 1637
 1638    private static string? ExtractGroupLabelFromPreviousSiblings(IElement element)
 1639    {
 1640        for (var sibling = element.PreviousElementSibling; sibling is not null; sibling = sibling.PreviousElementSibling
 1641        {
 1642            if (IsStandingsTableContainer(sibling))
 1643            {
 1644                foreach (var previousHeading in sibling.QuerySelectorAll("h1,h2,h3,h4,h5,h6").Reverse())
 1645                {
 1646                    var headingLabel = ExtractGroupLabel(previousHeading.TextContent);
 1647                    if (!string.IsNullOrWhiteSpace(headingLabel))
 1648                    {
 1649                        return headingLabel;
 1650                    }
 1651                }
 1652
 1653                break;
 1654            }
 1655
 1656            var heading = IsHeading(sibling)
 1657                ? sibling
 1658                : sibling.QuerySelector("h1,h2,h3,h4,h5,h6");
 1659            var label = ExtractGroupLabel(heading?.TextContent);
 1660            if (!string.IsNullOrWhiteSpace(label))
 1661            {
 1662                return label;
 1663            }
 1664
 1665            label = ExtractGroupLabel(sibling.TextContent);
 1666            if (!string.IsNullOrWhiteSpace(label))
 1667            {
 1668                return label;
 1669            }
 1670        }
 1671
 1672        return null;
 1673    }
 1674
 1675    private static bool ContainsOnlyCurrentStandingsTable(IElement candidate, IElement standingsTable)
 1676    {
 1677        var nestedStandingsTables = candidate.QuerySelectorAll("table.sporttabelle");
 1678        return nestedStandingsTables.Length == 1 && ReferenceEquals(nestedStandingsTables[0], standingsTable);
 1679    }
 1680
 1681    private static bool IsStandingsTableContainer(IElement element)
 1682    {
 1683        return element.Matches("table.sporttabelle") || element.QuerySelector("table.sporttabelle") is not null;
 1684    }
 1685
 1686    private static bool IsHeading(IElement element)
 1687    {
 1688        return element.TagName.ToUpperInvariant() is "H1" or "H2" or "H3" or "H4" or "H5" or "H6";
 1689    }
 1690
 1691    private static string? ExtractGroupLabel(string? text)
 1692    {
 1693        var normalized = NormalizeWhitespace(text);
 1694        if (string.IsNullOrWhiteSpace(normalized))
 1695        {
 1696            return null;
 1697        }
 1698
 1699        var match = Regex.Match(
 1700            normalized,
 1701            @"\b(?<prefix>Gruppe|Group)\s+(?<group>[A-Z])",
 1702            System.Text.RegularExpressions.RegexOptions.IgnoreCase);
 1703        if (!match.Success)
 1704        {
 1705            return null;
 1706        }
 1707
 1708        var prefix = match.Groups["prefix"].Value.Equals("group", StringComparison.OrdinalIgnoreCase)
 1709            ? "Group"
 1710            : "Gruppe";
 1711        return $"{prefix} {match.Groups["group"].Value.ToUpperInvariant()}";
 1712    }
 1713
 1714    private static string NormalizeWhitespace(string? value)
 1715    {
 1716        return string.IsNullOrWhiteSpace(value)
 1717            ? string.Empty
 1718            : Regex.Replace(value.Trim(), @"\s+", " ");
 1719    }
 1720
 1721    /// <summary>
 1722    /// Determines if the given time text indicates a cancelled match.
 1723    /// </summary>
 1724    /// <param name="timeText">The time text from the Kicktipp page.</param>
 1725    /// <returns>True if the match is cancelled ("Abgesagt" in German), false otherwise.</returns>
 1726    /// <remarks>
 1727    /// <para>
 1728    /// Cancelled matches on Kicktipp display "Abgesagt" instead of a date/time in the schedule.
 1729    /// These matches can still receive predictions, so we continue processing them rather than skipping.
 1730    /// </para>
 1731    /// <para>
 1732    /// <b>Design Decision:</b> We treat "Abgesagt" similar to an empty time cell and inherit the
 1733    /// previous valid time. This preserves database key consistency since the composite key
 1734    /// (HomeTeam, AwayTeam, StartsAt, ...) must remain stable across prediction operations.
 1735    /// </para>
 1736    /// <para>
 1737    /// See <c>docs/features/cancelled-matches.md</c> for complete design rationale.
 1738    /// </para>
 1739    /// </remarks>
 1740    private static bool IsCancelledTimeText(string timeText)
 1741    {
 1742        return string.Equals(timeText, "Abgesagt", StringComparison.OrdinalIgnoreCase);
 1743    }
 1744
 1745    private async Task<IDocument?> GetTippuebersichtDocumentAsync(string community, int? matchday)
 1746    {
 1747        try
 1748        {
 1749            var url = matchday.HasValue
 1750                ? $"{community}/tippuebersicht?spieltagIndex={matchday.Value}"
 1751                : $"{community}/tippuebersicht";
 1752
 1753            var response = await _httpClient.GetAsync(url);
 1754            if (!response.IsSuccessStatusCode)
 1755            {
 1756                _logger.LogError("Failed to fetch tippuebersicht page {Url}. Status: {StatusCode}", url, response.Status
 1757                return null;
 1758            }
 1759
 1760            var content = await response.Content.ReadAsStringAsync();
 1761            return await _browsingContext.OpenAsync(req => req.Content(content));
 1762        }
 1763        catch (Exception ex)
 1764        {
 1765            _logger.LogError(ex, "Error fetching tippuebersicht page for {Community} matchday {Matchday}", community, ma
 1766            return null;
 1767        }
 1768    }
 1769
 1770    private List<CollectedMatchOutcome> ParseTippuebersichtMatchdayOutcomes(IDocument document, int matchday)
 1771    {
 1772        var outcomes = new List<CollectedMatchOutcome>();
 1773
 1774        var matchTable = document.QuerySelector("#spielplanSpiele tbody");
 1775        if (matchTable == null)
 1776        {
 1777            _logger.LogWarning("Could not find tippuebersicht match table for matchday {Matchday}", matchday);
 1778            return outcomes;
 1779        }
 1780
 1781        var matchRows = matchTable.QuerySelectorAll("tr");
 1782        string lastValidTimeText = string.Empty;
 1783
 1784        foreach (var row in matchRows)
 1785        {
 1786            try
 1787            {
 1788                var cells = row.QuerySelectorAll("td");
 1789                if (cells.Length < 4)
 1790                {
 1791                    continue;
 1792                }
 1793
 1794                var timeText = cells[0].TextContent?.Trim() ?? string.Empty;
 1795                var homeTeam = cells[1].TextContent?.Trim() ?? string.Empty;
 1796                var awayTeam = cells[2].TextContent?.Trim() ?? string.Empty;
 1797
 1798                if (string.IsNullOrWhiteSpace(homeTeam) || string.IsNullOrWhiteSpace(awayTeam))
 1799                {
 1800                    continue;
 1801                }
 1802
 1803                var isCancelled = IsCancelledTimeText(timeText);
 1804                if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 1805                {
 1806                    if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 1807                    {
 1808                        timeText = lastValidTimeText;
 1809                    }
 1810                }
 1811                else
 1812                {
 1813                    lastValidTimeText = timeText;
 1814                }
 1815
 1816                var startsAt = ParseMatchDateTime(timeText);
 1817                var (homeGoals, awayGoals, availability) = ParseMatchOutcome(cells[3]);
 1818                var tippSpielId = ExtractTippSpielId(row.GetAttribute("data-url"));
 1819
 1820                outcomes.Add(new CollectedMatchOutcome(
 1821                    homeTeam,
 1822                    awayTeam,
 1823                    startsAt,
 1824                    matchday,
 1825                    homeGoals,
 1826                    awayGoals,
 1827                    availability,
 1828                    tippSpielId));
 1829            }
 1830            catch (Exception ex)
 1831            {
 1832                _logger.LogWarning(ex, "Error parsing tippuebersicht row for matchday {Matchday}", matchday);
 1833            }
 1834        }
 1835
 1836        _logger.LogInformation("Parsed {MatchCount} tippuebersicht matches for matchday {Matchday}", outcomes.Count, mat
 1837        return outcomes;
 1838    }
 1839
 1840    private List<KicktippCommunityParticipantSnapshot> ParseTippuebersichtParticipantSnapshots(
 1841        IDocument document,
 1842        int matchday,
 1843        IReadOnlyList<CollectedMatchOutcome> outcomes)
 1844    {
 1845        var rankingTable = document.QuerySelector("#ranking");
 1846        if (rankingTable == null)
 1847        {
 1848            _logger.LogWarning("Could not find tippuebersicht ranking table for matchday {Matchday}", matchday);
 1849            return [];
 1850        }
 1851
 1852        var completedMappings = BuildCompletedRankingEventMappings(rankingTable, outcomes);
 1853        if (completedMappings.Count == 0)
 1854        {
 1855            _logger.LogInformation("No completed ranking event mappings found for matchday {Matchday}", matchday);
 1856            return [];
 1857        }
 1858
 1859        var participantRows = rankingTable.QuerySelectorAll("tbody tr.teilnehmer");
 1860        var participants = new List<KicktippCommunityParticipantSnapshot>();
 1861
 1862        foreach (var row in participantRows)
 1863        {
 1864            try
 1865            {
 1866                var participantId = row.GetAttribute("data-teilnehmer-id")?.Trim() ?? string.Empty;
 1867                var displayName = row.QuerySelector(".mg_name")?.TextContent?.Trim() ?? string.Empty;
 1868                if (string.IsNullOrWhiteSpace(participantId) || string.IsNullOrWhiteSpace(displayName))
 1869                {
 1870                    continue;
 1871                }
 1872
 1873                var predictions = new List<KicktippCommunityMatchPrediction>();
 1874                foreach (var mapping in completedMappings.OrderBy(candidate => candidate.EventIndex))
 1875                {
 1876                    var predictionCell = row.QuerySelector($"td.ereignis{mapping.EventIndex}");
 1877                    if (predictionCell == null)
 1878                    {
 1879                        continue;
 1880                    }
 1881
 1882                    predictions.Add(ParseParticipantPredictionCell(predictionCell, mapping));
 1883                }
 1884
 1885                participants.Add(new KicktippCommunityParticipantSnapshot(
 1886                    participantId,
 1887                    displayName,
 1888                    predictions,
 1889                    ParseIntegerCell(row.QuerySelector("td.spieltagspunkte")),
 1890                    ParseIntegerCell(row.QuerySelector("td.gesamtpunkte"))));
 1891            }
 1892            catch (Exception ex)
 1893            {
 1894                _logger.LogWarning(ex, "Error parsing tippuebersicht participant row for matchday {Matchday}", matchday)
 1895            }
 1896        }
 1897
 1898        _logger.LogInformation("Parsed {ParticipantCount} tippuebersicht participants for matchday {Matchday}", particip
 1899        return participants;
 1900    }
 1901
 1902    private static (int? homeGoals, int? awayGoals, MatchOutcomeAvailability availability) ParseMatchOutcome(IElement re
 1903    {
 1904        var homeGoalText = resultCell.QuerySelector(".kicktipp-heim")?.TextContent?.Trim();
 1905        var awayGoalText = resultCell.QuerySelector(".kicktipp-gast")?.TextContent?.Trim();
 1906
 1907        if (int.TryParse(homeGoalText, out var homeGoals) && int.TryParse(awayGoalText, out var awayGoals))
 1908        {
 1909            return (homeGoals, awayGoals, MatchOutcomeAvailability.Completed);
 1910        }
 1911
 1912        return (null, null, MatchOutcomeAvailability.Pending);
 1913    }
 1914
 1915    private static string? ExtractTippSpielId(string? dataUrl)
 1916    {
 1917        if (string.IsNullOrWhiteSpace(dataUrl))
 1918        {
 1919            return null;
 1920        }
 1921
 1922        var match = Regex.Match(dataUrl, @"(?:\?|&)tippspielId=(\d+)");
 1923        return match.Success ? match.Groups[1].Value : null;
 1924    }
 1925
 1926    private List<CompletedRankingEventMapping> BuildCompletedRankingEventMappings(
 1927        IElement rankingTable,
 1928        IReadOnlyList<CollectedMatchOutcome> outcomes)
 1929    {
 1930        var outcomesByTippSpielId = outcomes
 1931            .Where(outcome => !string.IsNullOrWhiteSpace(outcome.TippSpielId))
 1932            .ToDictionary(outcome => outcome.TippSpielId!, StringComparer.Ordinal);
 1933        var outcomesByEventIndex = outcomes
 1934            .Select((outcome, index) => new { outcome, index })
 1935            .ToDictionary(pair => pair.index, pair => pair.outcome);
 1936
 1937        var mappings = new List<CompletedRankingEventMapping>();
 1938        foreach (var header in rankingTable.QuerySelectorAll("thead th.ereignis[data-spiel='true']"))
 1939        {
 1940            if (!int.TryParse(header.GetAttribute("data-index"), out var eventIndex))
 1941            {
 1942                continue;
 1943            }
 1944
 1945            var headerTippSpielId = ExtractTippSpielId(header.QuerySelector("a")?.GetAttribute("href"));
 1946            CollectedMatchOutcome? mappedOutcome = null;
 1947
 1948            if (!string.IsNullOrWhiteSpace(headerTippSpielId)
 1949                && outcomesByTippSpielId.TryGetValue(headerTippSpielId, out var byTippSpielId))
 1950            {
 1951                mappedOutcome = byTippSpielId;
 1952            }
 1953            else if (outcomesByEventIndex.TryGetValue(eventIndex, out var byEventIndex))
 1954            {
 1955                mappedOutcome = byEventIndex;
 1956            }
 1957
 1958            if (mappedOutcome is null || !mappedOutcome.HasOutcome)
 1959            {
 1960                continue;
 1961            }
 1962
 1963            var sourceMatchId = mappedOutcome.TippSpielId
 1964                ?? string.Join("|", mappedOutcome.Matchday, mappedOutcome.HomeTeam, mappedOutcome.AwayTeam);
 1965            mappings.Add(new CompletedRankingEventMapping(eventIndex, sourceMatchId, mappedOutcome.TippSpielId));
 1966        }
 1967
 1968        return mappings;
 1969    }
 1970
 1971    private static KicktippCommunityMatchPrediction ParseParticipantPredictionCell(
 1972        IElement predictionCell,
 1973        CompletedRankingEventMapping mapping)
 1974    {
 1975        var awardedPoints = ParseIntegerCell(predictionCell.QuerySelector("sub.p"));
 1976        var rawText = ExtractPredictionCellScoreText(predictionCell);
 1977        if (TryParseBetPrediction(rawText, out var prediction))
 1978        {
 1979            return new KicktippCommunityMatchPrediction(
 1980                mapping.EventIndex,
 1981                mapping.SourceMatchId,
 1982                mapping.TippSpielId,
 1983                KicktippCommunityPredictionStatus.Placed,
 1984                prediction,
 1985                awardedPoints);
 1986        }
 1987
 1988        return new KicktippCommunityMatchPrediction(
 1989            mapping.EventIndex,
 1990            mapping.SourceMatchId,
 1991            mapping.TippSpielId,
 1992            KicktippCommunityPredictionStatus.Missed,
 1993            null,
 1994            0);
 1995    }
 1996
 1997    private static string ExtractPredictionCellScoreText(IElement predictionCell)
 1998    {
 1999        return string.Concat(predictionCell.ChildNodes.Select(ExtractNodeText)).Trim();
 2000
 2001        static string ExtractNodeText(INode node)
 2002        {
 2003            if (node is IElement element && element.Matches("sub.p"))
 2004            {
 2005                return string.Empty;
 2006            }
 2007
 2008            return node.ChildNodes.Length == 0
 2009                ? node.TextContent ?? string.Empty
 2010                : string.Concat(node.ChildNodes.Select(ExtractNodeText));
 2011        }
 2012    }
 2013
 2014    private static bool TryParseBetPrediction(string? value, out BetPrediction? prediction)
 2015    {
 2016        prediction = null;
 2017        if (string.IsNullOrWhiteSpace(value))
 2018        {
 2019            return false;
 2020        }
 2021
 2022        var sanitized = Regex.Replace(value, @"\s+", string.Empty);
 2023        var match = Regex.Match(sanitized, @"^(\d+):(\d+)$");
 2024        if (!match.Success)
 2025        {
 2026            return false;
 2027        }
 2028
 2029        if (!int.TryParse(match.Groups[1].Value, out var homeGoals)
 2030            || !int.TryParse(match.Groups[2].Value, out var awayGoals))
 2031        {
 2032            return false;
 2033        }
 2034
 2035        prediction = new BetPrediction(homeGoals, awayGoals);
 2036        return true;
 2037    }
 2038
 2039    private static int ParseIntegerCell(IElement? element)
 2040    {
 2041        if (element == null)
 2042        {
 2043            return 0;
 2044        }
 2045
 2046        var raw = element.TextContent?.Trim() ?? string.Empty;
 2047        return int.TryParse(raw, out var value) ? value : 0;
 2048    }
 2049
 12050    private sealed record CompletedRankingEventMapping(
 12051        int EventIndex,
 12052        string SourceMatchId,
 12053        string? TippSpielId);
 2054
 2055    /// <inheritdoc />
 2056    public async Task<Dictionary<Match, BetPrediction?>> GetPlacedPredictionsAsync(string community)
 2057    {
 2058        try
 2059        {
 2060            var url = $"{community}/tippabgabe";
 2061            var response = await _httpClient.GetAsync(url);
 2062
 2063            if (!response.IsSuccessStatusCode)
 2064            {
 2065                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 2066                return new Dictionary<Match, BetPrediction?>();
 2067            }
 2068
 2069            var content = await response.Content.ReadAsStringAsync();
 2070            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 2071
 2072            var placedPredictions = new Dictionary<Match, BetPrediction?>();
 2073
 2074            // Extract matchday from the page
 2075            var currentMatchday = ExtractMatchdayFromPage(document);
 2076            _logger.LogDebug("Extracted matchday for placed predictions: {Matchday}", currentMatchday);
 2077
 2078            // Parse matches from the tippabgabe table
 2079            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 2080            if (matchTable == null)
 2081            {
 2082                _logger.LogWarning("Could not find tippabgabe table");
 2083                return placedPredictions;
 2084            }
 2085
 2086            var matchRows = matchTable.QuerySelectorAll("tr");
 2087            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 2088
 2089            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 2090
 2091            foreach (var row in matchRows)
 2092            {
 2093                try
 2094                {
 2095                    var cells = row.QuerySelectorAll("td");
 2096                    if (cells.Length >= 4)
 2097                    {
 2098                        // Extract match details from table cells
 2099                        var timeText = cells[0].TextContent?.Trim() ?? "";
 2100                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 2101                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 2102
 2103                        _logger.LogDebug("Raw time text for {HomeTeam} vs {AwayTeam}: '{TimeText}'", homeTeam, awayTeam,
 2104
 2105                        // Check if match is cancelled ("Abgesagt" in German)
 2106                        // Cancelled matches still accept predictions on Kicktipp, so we process them.
 2107                        // See docs/features/cancelled-matches.md for design rationale.
 2108                        var isCancelled = IsCancelledTimeText(timeText);
 2109
 2110                        // Handle date inheritance: if timeText is empty or cancelled, use the last valid time
 2111                        // This preserves database key consistency (startsAt is part of the composite key)
 2112                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 2113                        {
 2114                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 2115                            {
 2116                                if (isCancelled)
 2117                                {
 2118                                    _logger.LogWarning(
 2119                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 2120                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 2121                                        homeTeam, awayTeam, lastValidTimeText);
 2122                                }
 2123                                else
 2124                                {
 2125                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 2126                                }
 2127                                timeText = lastValidTimeText;
 2128                            }
 2129                            else
 2130                            {
 2131                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 2132                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 2133                            }
 2134                        }
 2135                        else
 2136                        {
 2137                            // Update the last valid time for future inheritance
 2138                            lastValidTimeText = timeText;
 2139                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 2140                        }
 2141
 2142                        // Look for betting inputs to get placed predictions
 2143                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 2144                        if (bettingInputs.Length >= 2)
 2145                        {
 2146                            var homeInput = bettingInputs[0] as IHtmlInputElement;
 2147                            var awayInput = bettingInputs[1] as IHtmlInputElement;
 2148
 2149                            // Parse the date/time
 2150                            var startsAt = ParseMatchDateTime(timeText);
 2151                            var match = new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled);
 2152
 2153                            // Check if predictions are placed (inputs have values)
 2154                            var homeValue = homeInput?.Value?.Trim();
 2155                            var awayValue = awayInput?.Value?.Trim();
 2156
 2157                            BetPrediction? prediction = null;
 2158                            if (!string.IsNullOrEmpty(homeValue) && !string.IsNullOrEmpty(awayValue))
 2159                            {
 2160                                if (int.TryParse(homeValue, out var homeGoals) && int.TryParse(awayValue, out var awayGo
 2161                                {
 2162                                    prediction = new BetPrediction(homeGoals, awayGoals);
 2163                                    _logger.LogDebug("Found placed prediction: {HomeTeam} vs {AwayTeam} = {Prediction}",
 2164                                }
 2165                                else
 2166                                {
 2167                                    _logger.LogWarning("Could not parse prediction values for {HomeTeam} vs {AwayTeam}: 
 2168                                }
 2169                            }
 2170                            else
 2171                            {
 2172                                _logger.LogDebug("No prediction placed for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 2173                            }
 2174
 2175                            placedPredictions[match] = prediction;
 2176                        }
 2177                    }
 2178                }
 2179                catch (Exception ex)
 2180                {
 2181                    _logger.LogWarning(ex, "Error parsing match row");
 2182                    continue;
 2183                }
 2184            }
 2185
 2186            _logger.LogInformation("Successfully parsed {MatchCount} matches with {PlacedCount} placed predictions",
 2187                placedPredictions.Count, placedPredictions.Values.Count(p => p != null));
 2188            return placedPredictions;
 2189        }
 2190        catch (Exception ex)
 2191        {
 2192            _logger.LogError(ex, "Exception in GetPlacedPredictionsAsync");
 2193            return new Dictionary<Match, BetPrediction?>();
 2194        }
 2195    }
 2196
 2197    private int ExtractMatchdayFromPage(IDocument document)
 2198    {
 2199        try
 2200        {
 2201            // Hidden fields are the most stable source across league and tournament pages.
 2202            foreach (var input in document.QuerySelectorAll("input"))
 2203            {
 2204                var name = input.GetAttribute("name") ?? string.Empty;
 2205                var value = input.GetAttribute("value") ?? string.Empty;
 2206                if (name.Contains("spieltag", StringComparison.OrdinalIgnoreCase) &&
 2207                    TryParsePositiveInteger(value, out var matchdayFromHiddenInput))
 2208                {
 2209                    _logger.LogDebug("Extracted matchday from hidden input {InputName}: {Matchday}", name, matchdayFromH
 2210                    return matchdayFromHiddenInput;
 2211                }
 2212            }
 2213
 2214            foreach (var select in document.QuerySelectorAll("select"))
 2215            {
 2216                var name = select.GetAttribute("name") ?? string.Empty;
 2217                if (!name.Contains("spieltag", StringComparison.OrdinalIgnoreCase))
 2218                {
 2219                    continue;
 2220                }
 2221
 2222                var selectedRoundOption = select.QuerySelector("option[selected]");
 2223                var selectedRoundValue = selectedRoundOption?.GetAttribute("value");
 2224                if (TryParsePositiveInteger(selectedRoundValue, out var matchdayFromSelectedOption))
 2225                {
 2226                    _logger.LogDebug("Extracted matchday from selected round option: {Matchday}", matchdayFromSelectedOp
 2227                    return matchdayFromSelectedOption;
 2228                }
 2229            }
 2230
 2231            // Fallback: extract any numeric round marker from common navigation elements.
 2232            foreach (var element in document.QuerySelectorAll(".prevnextTitle a, .prevnextTitle, .pagination .active, .p
 2233            {
 2234                var text = NormalizeWhitespace(element.TextContent);
 2235                if (TryExtractFirstPositiveInteger(text, out var matchdayFromNavigation))
 2236                {
 2237                    _logger.LogDebug("Extracted matchday from navigation text '{NavigationText}': {Matchday}", text, mat
 2238                    return matchdayFromNavigation;
 2239                }
 2240            }
 2241
 2242            _logger.LogWarning("Could not extract matchday from page, defaulting to 1");
 2243            return 1;
 2244        }
 2245        catch (Exception ex)
 2246        {
 2247            _logger.LogError(ex, "Error extracting matchday from page, defaulting to 1");
 2248            return 1;
 2249        }
 2250    }
 2251
 2252    private static bool TryExtractFirstPositiveInteger(string? text, out int value)
 2253    {
 2254        value = 0;
 2255        if (string.IsNullOrWhiteSpace(text))
 2256        {
 2257            return false;
 2258        }
 2259
 2260        var match = Regex.Match(text, @"\b(\d+)\b");
 2261        return match.Success && TryParsePositiveInteger(match.Groups[1].Value, out value);
 2262    }
 2263
 2264    private static bool TryParsePositiveInteger(string? text, out int value)
 2265    {
 2266        return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) && value > 0;
 2267    }
 2268
 2269    /// <inheritdoc />
 2270    public async Task<List<BonusQuestion>> GetOpenBonusQuestionsAsync(string community)
 2271    {
 2272        try
 2273        {
 2274            var url = $"{community}/tippabgabe?bonus=true";
 2275            var response = await _httpClient.GetAsync(url);
 2276
 2277            if (!response.IsSuccessStatusCode)
 2278            {
 2279                _logger.LogError("Failed to fetch tippabgabe page for bonus questions. Status: {StatusCode}", response.S
 2280                return new List<BonusQuestion>();
 2281            }
 2282
 2283            var content = await response.Content.ReadAsStringAsync();
 2284            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 2285
 2286            var bonusQuestions = new List<BonusQuestion>();
 2287
 2288            // Parse bonus questions from the tippabgabeFragen table
 2289            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 2290            if (bonusTable == null)
 2291            {
 2292                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 2293                return bonusQuestions;
 2294            }
 2295
 2296            var questionRows = bonusTable.QuerySelectorAll("tr");
 2297            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows", questionRows.Length);
 2298
 2299            foreach (var row in questionRows)
 2300            {
 2301                var cells = row.QuerySelectorAll("td");
 2302                if (cells.Length < 3) continue;
 2303
 2304                // Extract deadline and question text
 2305                var deadlineText = cells[0]?.TextContent?.Trim();
 2306                var questionText = cells[1]?.TextContent?.Trim();
 2307
 2308                if (string.IsNullOrEmpty(questionText)) continue;
 2309
 2310                // Parse deadline
 2311                var deadline = ParseMatchDateTime(deadlineText ?? "");
 2312
 2313                // Extract options from select elements
 2314                var tipCell = cells[2];
 2315                var selectElements = tipCell?.QuerySelectorAll("select");
 2316                var options = new List<BonusQuestionOption>();
 2317                string? formFieldName = null;
 2318                int maxSelections = 1; // Default to single selection
 2319
 2320                if (selectElements != null && selectElements.Length > 0)
 2321                {
 2322                    // The number of select elements indicates how many selections are allowed
 2323                    maxSelections = selectElements.Length;
 2324
 2325                    // Use the first select element to get the available options
 2326                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 2327                    formFieldName = firstSelect?.Name;
 2328
 2329                    var optionElements = firstSelect?.QuerySelectorAll("option");
 2330                    if (optionElements != null)
 2331                    {
 2332                        foreach (var option in optionElements.Cast<IHtmlOptionElement>())
 2333                        {
 2334                            if (option.Value != "-1" && !string.IsNullOrEmpty(option.Text))
 2335                            {
 2336                                options.Add(new BonusQuestionOption(option.Value, option.Text.Trim()));
 2337                            }
 2338                        }
 2339                    }
 2340                }
 2341
 2342                if (options.Any())
 2343                {
 2344                    bonusQuestions.Add(new BonusQuestion(
 2345                        Text: questionText,
 2346                        Deadline: deadline,
 2347                        Options: options,
 2348                        MaxSelections: maxSelections,
 2349                        FormFieldName: formFieldName
 2350                    ));
 2351                }
 2352            }
 2353
 2354            _logger.LogInformation("Successfully parsed {QuestionCount} bonus questions", bonusQuestions.Count);
 2355            return bonusQuestions;
 2356        }
 2357        catch (Exception ex)
 2358        {
 2359            _logger.LogError(ex, "Exception in GetOpenBonusQuestionsAsync");
 2360            return new List<BonusQuestion>();
 2361        }
 2362    }
 2363
 2364    /// <inheritdoc />
 2365    public async Task<Dictionary<string, BonusPrediction?>> GetPlacedBonusPredictionsAsync(string community)
 2366    {
 2367        try
 2368        {
 2369            var url = $"{community}/tippabgabe?bonus=true";
 2370            var response = await _httpClient.GetAsync(url);
 2371
 2372            if (!response.IsSuccessStatusCode)
 2373            {
 2374                _logger.LogError("Failed to fetch tippabgabe page for placed bonus predictions. Status: {StatusCode}", r
 2375                return new Dictionary<string, BonusPrediction?>();
 2376            }
 2377
 2378            var content = await response.Content.ReadAsStringAsync();
 2379            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 2380
 2381            var placedPredictions = new Dictionary<string, BonusPrediction?>();
 2382
 2383            // Parse bonus questions from the tippabgabeFragen table
 2384            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 2385            if (bonusTable == null)
 2386            {
 2387                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 2388                return placedPredictions;
 2389            }
 2390
 2391            var questionRows = bonusTable.QuerySelectorAll("tr");
 2392            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows for placed predictions", questionRo
 2393
 2394            foreach (var row in questionRows)
 2395            {
 2396                var cells = row.QuerySelectorAll("td");
 2397                if (cells.Length < 3) continue;
 2398
 2399                // Extract question text
 2400                var questionText = cells[1]?.TextContent?.Trim();
 2401                if (string.IsNullOrEmpty(questionText)) continue;
 2402
 2403                // Extract current selections from select elements
 2404                var tipCell = cells[2];
 2405                var selectElements = tipCell?.QuerySelectorAll("select");
 2406
 2407                if (selectElements != null && selectElements.Length > 0)
 2408                {
 2409                    // Extract form field name from the first select element
 2410                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 2411                    var formFieldName = firstSelect?.Name;
 2412
 2413                    var selectedOptionIds = new List<string>();
 2414
 2415                    // Check each select element for its current selection
 2416                    foreach (var selectElement in selectElements.Cast<IHtmlSelectElement>())
 2417                    {
 2418                        var selectedOption = selectElement.SelectedOptions.FirstOrDefault();
 2419                        if (selectedOption != null && selectedOption.Value != "-1" && !string.IsNullOrEmpty(selectedOpti
 2420                        {
 2421                            selectedOptionIds.Add(selectedOption.Value);
 2422                        }
 2423                    }
 2424
 2425                    // Use form field name as key, fall back to question text
 2426                    var dictionaryKey = formFieldName ?? questionText;
 2427
 2428                    // Only create a prediction if there are actual selections
 2429                    if (selectedOptionIds.Any())
 2430                    {
 2431                        placedPredictions[dictionaryKey] = new BonusPrediction(selectedOptionIds);
 2432                    }
 2433                    else
 2434                    {
 2435                        placedPredictions[dictionaryKey] = null; // No prediction placed
 2436                    }
 2437                }
 2438            }
 2439
 2440            _logger.LogInformation("Successfully retrieved placed predictions for {QuestionCount} bonus questions", plac
 2441            return placedPredictions;
 2442        }
 2443        catch (Exception ex)
 2444        {
 2445            _logger.LogError(ex, "Exception in GetPlacedBonusPredictionsAsync");
 2446            return new Dictionary<string, BonusPrediction?>();
 2447        }
 2448    }
 2449
 2450    /// <inheritdoc />
 2451    public async Task<bool> PlaceBonusPredictionsAsync(string community, Dictionary<string, BonusPrediction> predictions
 2452    {
 2453        try
 2454        {
 2455            if (!predictions.Any())
 2456            {
 2457                _logger.LogInformation("No bonus predictions to place");
 2458                return true;
 2459            }
 2460
 2461            var url = $"{community}/tippabgabe?bonus=true";
 2462            var response = await _httpClient.GetAsync(url);
 2463
 2464            if (!response.IsSuccessStatusCode)
 2465            {
 2466                _logger.LogError("Failed to access betting page for bonus predictions. Status: {StatusCode}", response.S
 2467                return false;
 2468            }
 2469
 2470            var pageContent = await response.Content.ReadAsStringAsync();
 2471            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 2472
 2473            // Find the bet form
 2474            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 2475            if (betForm == null)
 2476            {
 2477                _logger.LogWarning("Could not find betting form on the page");
 2478                return false;
 2479            }
 2480
 2481            var formData = new List<KeyValuePair<string, string>>();
 2482
 2483            // Copy hidden inputs from the original form
 2484            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 2485            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 2486            {
 2487                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 2488                {
 2489                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 2490                }
 2491            }
 2492
 2493            // Copy existing match predictions to avoid overwriting them
 2494            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 2495            foreach (var input in allInputs)
 2496            {
 2497                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 2498                {
 2499                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 2500                }
 2501            }
 2502
 2503            // Add bonus predictions
 2504            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 2505            if (bonusTable != null)
 2506            {
 2507                var questionRows = bonusTable.QuerySelectorAll("tr");
 2508
 2509                foreach (var row in questionRows)
 2510                {
 2511                    var cells = row.QuerySelectorAll("td");
 2512                    if (cells.Length < 3) continue;
 2513
 2514                    var tipCell = cells[2];
 2515                    var selectElements = tipCell?.QuerySelectorAll("select");
 2516
 2517                    if (selectElements != null)
 2518                    {
 2519                        var selectArray = selectElements.Cast<IHtmlSelectElement>().ToArray();
 2520
 2521                        // Check if we have a prediction for this question based on form field name match
 2522                        var matchingPrediction = predictions.FirstOrDefault(p =>
 2523                            selectArray.Any(sel => sel.Name == p.Key) ||
 2524                            selectArray.Any(sel => sel.Name?.Contains(p.Key) == true));
 2525
 2526                        if (matchingPrediction.Value != null && matchingPrediction.Value.SelectedOptionIds.Any())
 2527                        {
 2528                            var selectedOptions = matchingPrediction.Value.SelectedOptionIds;
 2529
 2530                            // For multi-selection questions, we need to fill multiple select elements
 2531                            for (int i = 0; i < Math.Min(selectArray.Length, selectedOptions.Count); i++)
 2532                            {
 2533                                var selectElement = selectArray[i];
 2534                                var fieldName = selectElement.Name;
 2535                                if (string.IsNullOrEmpty(fieldName)) continue;
 2536
 2537                                var selectedOptionId = selectedOptions[i];
 2538
 2539                                // Check if this option exists in the select element
 2540                                var optionExists = selectElement.QuerySelectorAll("option")
 2541                                    .Cast<IHtmlOptionElement>()
 2542                                    .Any(opt => opt.Value == selectedOptionId);
 2543
 2544                                if (optionExists)
 2545                                {
 2546                                    formData.Add(new KeyValuePair<string, string>(fieldName, selectedOptionId));
 2547                                    _logger.LogDebug("Added bonus prediction for field {FieldName}: {OptionId} (selectio
 2548                                        fieldName, selectedOptionId, i + 1);
 2549                                }
 2550                                else
 2551                                {
 2552                                    _logger.LogWarning("Option {OptionId} not found for field {FieldName}", selectedOpti
 2553                                }
 2554                            }
 2555                        }
 2556                    }
 2557                }
 2558            }
 2559
 2560            // Find submit button
 2561            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 2562            if (submitButton != null)
 2563            {
 2564                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 2565                {
 2566                    formData.Add(new KeyValuePair<string, string>(inputSubmit.Name, inputSubmit.Value ?? "Submit"));
 2567                }
 2568                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 2569                {
 2570                    formData.Add(new KeyValuePair<string, string>(buttonSubmit.Name, buttonSubmit.Value ?? "Submit"));
 2571                }
 2572            }
 2573            else
 2574            {
 2575                // Fallback to default submit button name
 2576                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 2577            }
 2578
 2579            // Submit form
 2580            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 2581                (betForm.Action.StartsWith("http") ? betForm.Action :
 2582                 betForm.Action.StartsWith("/") ? betForm.Action :
 2583                 $"{community}/{betForm.Action}");
 2584
 2585            var formContent = new FormUrlEncodedContent(formData);
 2586            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 2587
 2588            if (submitResponse.IsSuccessStatusCode)
 2589            {
 2590                _logger.LogInformation("✓ Successfully submitted {PredictionCount} bonus predictions!", predictions.Coun
 2591                return true;
 2592            }
 2593            else
 2594            {
 2595                _logger.LogError("✗ Failed to submit bonus predictions. Status: {StatusCode}", submitResponse.StatusCode
 2596                return false;
 2597            }
 2598        }
 2599        catch (Exception ex)
 2600        {
 2601            _logger.LogError(ex, "Exception during bonus prediction placement");
 2602            return false;
 2603        }
 2604    }
 2605
 2606    /// <summary>
 2607    /// Expands match annotation abbreviations to their full text.
 2608    /// </summary>
 2609    /// <param name="annotation">The abbreviated annotation (e.g., "n.E.", "n.V.")</param>
 2610    /// <returns>The expanded annotation or null if empty</returns>
 2611    private static string? ExpandAnnotation(string? annotation)
 2612    {
 2613        if (string.IsNullOrWhiteSpace(annotation))
 2614            return null;
 2615
 2616        return annotation.Trim() switch
 2617        {
 2618            "n.E." => "nach Elfmeterschießen",
 2619            "n.V." => "nach Verlängerung",
 2620            _ => annotation.Trim() // Return as-is if not recognized
 2621        };
 2622    }
 2623
 2624    public void Dispose()
 2625    {
 2626        _httpClient?.Dispose();
 2627        _browsingContext?.Dispose();
 2628    }
 2629}

Methods/Properties

.ctor(int, string, string)