< Summary

Information
Class: KicktippIntegration.KicktippClient
Assembly: KicktippIntegration
File(s): /home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/KicktippClient.cs
Line coverage
87%
Covered lines: 1104
Uncovered lines: 156
Coverable lines: 1260
Total lines: 2629
Line coverage: 87.6%
Branch coverage
78%
Covered branches: 779
Total branches: 992
Branch coverage: 78.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%66100%
GetOpenPredictionsAsync()71.88%363284.91%
PlaceBetAsync()85.9%847890%
PlaceBetsAsync()84.09%918892.86%
GetStandingsAsync()69.23%807892.86%
GetMatchesWithHistoryAsync(...)100%11100%
GetMatchesWithHistoryAsync(...)100%11100%
GetMatchesWithHistoryAsync()92.86%302886.76%
GetCurrentTippuebersichtMatchdayAsync()100%22100%
GetMatchdayOutcomesAsync()100%66100%
GetCommunityMatchdaySnapshotAsync()66.67%6691.3%
GetHomeAwayHistoryAsync()90%222083.33%
GetHeadToHeadHistoryAsync()90%222082.61%
GetHeadToHeadDetailedHistoryAsync()90%222081.82%
IsMatchOnPage(...)75%191676.92%
ExtractMatchWithHistoryFromSpielinfoPage(...)64.71%623471.11%
ExtractTeamHistory(...)75%1308481.36%
ExtractHeadToHeadHistory(...)65.91%514484.62%
FindNextMatchLink(...)75%9872.22%
ParseMatchDateTime(...)100%4480%
ExtractStandingsGroupName(...)85%212088.89%
ExtractGroupLabelFromPreviousSiblings(...)87.5%161694.12%
ContainsOnlyCurrentStandingsTable(...)50%22100%
IsStandingsTableContainer(...)50%22100%
IsHeading(...)100%1212100%
ExtractGroupLabel(...)83.33%66100%
NormalizeWhitespace(...)100%22100%
IsCancelledTimeText(...)100%11100%
GetTippuebersichtDocumentAsync()100%4476.92%
ParseTippuebersichtMatchdayOutcomes(...)76.92%262692.31%
ParseTippuebersichtParticipantSnapshots(...)65.38%372675%
ParseMatchOutcome(...)66.67%1212100%
ExtractTippSpielId(...)75%44100%
BuildCompletedRankingEventMappings(...)78.57%292888.24%
ParseParticipantPredictionCell(...)100%22100%
ExtractPredictionCellScoreText(...)100%22100%
ExtractNodeText()60%1010100%
TryParseBetPrediction(...)62.5%8883.33%
ParseIntegerCell(...)62.5%88100%
.ctor(...)100%11100%
GetPlacedPredictionsAsync()77.08%504890.63%
ExtractMatchdayFromPage(...)62.5%572461.54%
TryExtractFirstPositiveInteger(...)50%4480%
TryParsePositiveInteger(...)50%22100%
GetOpenBonusQuestionsAsync()80%404093.88%
GetPlacedBonusPredictionsAsync()85.29%353492.31%
PlaceBonusPredictionsAsync()90%606095.71%
ExpandAnnotation(...)100%66100%
Dispose()0%2040%

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{
 121    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
 128    public KicktippClient(HttpClient httpClient, ILogger<KicktippClient> logger, IMemoryCache cache)
 29    {
 130        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
 131        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 132        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
 33
 134        var config = Configuration.Default.WithDefaultLoader();
 135        _browsingContext = BrowsingContext.New(config);
 136    }
 37
 38    /// <inheritdoc />
 39    public async Task<List<Match>> GetOpenPredictionsAsync(string community)
 40    {
 41        try
 42        {
 143            var url = $"{community}/tippabgabe";
 144            var response = await _httpClient.GetAsync(url);
 45
 146            if (!response.IsSuccessStatusCode)
 47            {
 148                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 149                return new List<Match>();
 50            }
 51
 152            var content = await response.Content.ReadAsStringAsync();
 153            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 54
 155            var matches = new List<Match>();
 56
 57            // Extract matchday from the page
 158            var currentMatchday = ExtractMatchdayFromPage(document);
 159            _logger.LogDebug("Extracted matchday: {Matchday}", currentMatchday);
 60
 61            // Parse matches from the tippabgabe table
 162            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 163            if (matchTable == null)
 64            {
 165                _logger.LogWarning("Could not find tippabgabe table");
 166                return matches;
 67            }
 68
 169            var matchRows = matchTable.QuerySelectorAll("tr");
 170            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 71
 172            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 73
 174            foreach (var row in matchRows)
 75            {
 76                try
 77                {
 178                    var cells = row.QuerySelectorAll("td");
 179                    if (cells.Length >= 4)
 80                    {
 81                        // Extract match details from table cells
 182                        var timeText = cells[0].TextContent?.Trim() ?? "";
 183                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 184                        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.
 189                        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)
 193                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 94                        {
 195                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 96                            {
 197                                if (isCancelled)
 98                                {
 199                                    _logger.LogWarning(
 1100                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 1101                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 1102                                        homeTeam, awayTeam, lastValidTimeText);
 103                                }
 104                                else
 105                                {
 1106                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 107                                }
 1108                                timeText = lastValidTimeText;
 109                            }
 110                            else
 111                            {
 0112                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 0113                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 114                            }
 115                        }
 116                        else
 117                        {
 118                            // Update the last valid time for future inheritance
 1119                            lastValidTimeText = timeText;
 1120                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 121                        }
 122
 123                        // Check if this row has betting inputs (indicates open match)
 1124                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 1125                        if (bettingInputs.Length >= 2)
 126                        {
 1127                            _logger.LogDebug("Found open match: {HomeTeam} vs {AwayTeam} at {Time}{Cancelled}",
 1128                                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"
 1132                            var startsAt = ParseMatchDateTime(timeText);
 133
 1134                            matches.Add(new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled));
 135                        }
 136                    }
 1137                }
 0138                catch (Exception ex)
 139                {
 0140                    _logger.LogWarning(ex, "Error parsing match row");
 0141                    continue;
 142                }
 143            }
 144
 1145            _logger.LogInformation("Successfully parsed {MatchCount} open matches", matches.Count);
 1146            return matches;
 147        }
 0148        catch (Exception ex)
 149        {
 0150            _logger.LogError(ex, "Exception in GetOpenPredictionsAsync");
 0151            return new List<Match>();
 152        }
 1153    }
 154
 155    /// <inheritdoc />
 156    public async Task<bool> PlaceBetAsync(string community, Match match, BetPrediction prediction, bool overrideBet = fa
 157    {
 158        try
 159        {
 1160            var url = $"{community}/tippabgabe";
 1161            var response = await _httpClient.GetAsync(url);
 162
 1163            if (!response.IsSuccessStatusCode)
 164            {
 1165                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 1166                return false;
 167            }
 168
 1169            var pageContent = await response.Content.ReadAsStringAsync();
 1170            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 171
 172            // Find the bet form
 1173            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 1174            if (betForm == null)
 175            {
 1176                _logger.LogWarning("Could not find betting form on the page");
 1177                return false;
 178            }
 179
 180            // Find the main content area
 1181            var contentArea = document.QuerySelector("#kicktipp-content");
 1182            if (contentArea == null)
 183            {
 1184                _logger.LogWarning("Could not find content area on the betting page");
 1185                return false;
 186            }
 187
 188            // Find the table with predictions
 1189            var tbody = contentArea.QuerySelector("tbody");
 1190            if (tbody == null)
 191            {
 1192                _logger.LogWarning("No betting table found");
 1193                return false;
 194            }
 195
 1196            var rows = tbody.QuerySelectorAll("tr");
 1197            var formData = new List<KeyValuePair<string, string>>();
 1198            var matchFound = false;
 199
 200            // Copy hidden inputs from the original form
 1201            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 1202            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 203            {
 1204                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 205                {
 1206                    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
 1211            foreach (var row in rows)
 212            {
 1213                var cells = row.QuerySelectorAll("td");
 1214                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 215
 216                try
 217                {
 1218                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1219                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 220
 1221                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 0222                        continue;
 223
 224                    // Check if this is the match we want to bet on
 1225                    if (homeTeam == match.HomeTeam && roadTeam == match.AwayTeam)
 226                    {
 227                        // Find bet input fields in the row
 1228                        var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1229                        var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 230
 1231                        if (homeInput == null || awayInput == null)
 232                        {
 1233                            _logger.LogWarning("No betting inputs found for {Match}, skipping", match);
 1234                            continue;
 235                        }
 236
 237                        // Check if bets are already placed
 1238                        var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 1239                        var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 240
 1241                        if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBet)
 242                        {
 1243                            var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 1244                            _logger.LogInformation("{Match} - skipped, already placed {ExistingBet}", match, existingBet
 1245                            return true; // Consider this successful - bet already exists
 246                        }
 247
 248                        // Add bet to form data
 1249                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 250                        {
 1251                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString(
 1252                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString(
 1253                            matchFound = true;
 1254                            _logger.LogInformation("{Match} - betting {Prediction}", match, prediction);
 255                        }
 256                        else
 257                        {
 0258                            _logger.LogWarning("{Match} - input field names are missing, skipping", match);
 0259                            continue;
 260                        }
 261
 1262                        break; // Found our match, no need to continue
 263                    }
 1264                }
 0265                catch (Exception ex)
 266                {
 0267                    _logger.LogError(ex, "Error processing betting row");
 0268                    continue;
 269                }
 270            }
 271
 1272            if (!matchFound)
 273            {
 1274                _logger.LogWarning("Match {Match} not found in betting form", match);
 1275                return false;
 276            }
 277
 278            // Add other input fields that might have existing values
 1279            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 1280            foreach (var input in allInputs)
 281            {
 1282                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 283                {
 284                    // Only add if we haven't already added this field
 1285                    if (!formData.Any(kv => kv.Key == input.Name))
 286                    {
 1287                        formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 288                    }
 289                }
 290            }
 291
 292            // Find submit button
 1293            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 1294            var submitName = "submitbutton"; // Default from Python
 295
 1296            if (submitButton != null)
 297            {
 1298                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 299                {
 1300                    submitName = inputSubmit.Name;
 1301                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 302                }
 1303                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 304                {
 1305                    submitName = buttonSubmit.Name;
 1306                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 307                }
 308            }
 309            else
 310            {
 311                // Fallback to default submit button name
 1312                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 313            }
 314
 315            // Submit form
 1316            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 1317                (betForm.Action.StartsWith("http") ? betForm.Action :
 1318                 betForm.Action.StartsWith("/") ? betForm.Action :
 1319                 $"{community}/{betForm.Action}");
 320
 1321            var formContent = new FormUrlEncodedContent(formData);
 1322            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 323
 1324            if (submitResponse.IsSuccessStatusCode)
 325            {
 1326                _logger.LogInformation("✓ Successfully submitted bet for {Match}!", match);
 1327                return true;
 328            }
 329            else
 330            {
 1331                _logger.LogError("✗ Failed to submit bet. Status: {StatusCode}", submitResponse.StatusCode);
 1332                return false;
 333            }
 334        }
 0335        catch (Exception ex)
 336        {
 0337            _logger.LogError(ex, "Exception during bet placement");
 0338            return false;
 339        }
 1340    }
 341
 342    /// <inheritdoc />
 343    public async Task<bool> PlaceBetsAsync(string community, Dictionary<Match, BetPrediction> bets, bool overrideBets = 
 344    {
 345        try
 346        {
 1347            var url = $"{community}/tippabgabe";
 1348            var response = await _httpClient.GetAsync(url);
 349
 1350            if (!response.IsSuccessStatusCode)
 351            {
 1352                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 1353                return false;
 354            }
 355
 1356            var pageContent = await response.Content.ReadAsStringAsync();
 1357            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 358
 359            // Find the bet form
 1360            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 1361            if (betForm == null)
 362            {
 1363                _logger.LogWarning("Could not find betting form on the page");
 1364                return false;
 365            }
 366
 367            // Find the main content area
 1368            var contentArea = document.QuerySelector("#kicktipp-content");
 1369            if (contentArea == null)
 370            {
 1371                _logger.LogWarning("Could not find content area on the betting page");
 1372                return false;
 373            }
 374
 375            // Find the table with predictions
 1376            var tbody = contentArea.QuerySelector("tbody");
 1377            if (tbody == null)
 378            {
 1379                _logger.LogWarning("No betting table found");
 1380                return false;
 381            }
 382
 1383            var rows = tbody.QuerySelectorAll("tr");
 1384            var formData = new List<KeyValuePair<string, string>>();
 1385            var betsPlaced = 0;
 1386            var betsSkipped = 0;
 387
 388            // Add hidden fields from the form
 1389            var hiddenInputs = betForm.QuerySelectorAll("input[type=hidden]").OfType<IHtmlInputElement>();
 1390            foreach (var input in hiddenInputs)
 391            {
 1392                if (!string.IsNullOrEmpty(input.Name) && input.Value != null)
 393                {
 1394                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 395                }
 396            }
 397
 398            // Process all matches in the form
 1399            foreach (var row in rows)
 400            {
 1401                var cells = row.QuerySelectorAll("td");
 1402                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 403
 404                try
 405                {
 1406                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1407                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 408
 1409                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 1410                        continue;
 411
 412                    // Check if we have a bet for this match
 1413                    var matchKey = bets.Keys.FirstOrDefault(m => m.HomeTeam == homeTeam && m.AwayTeam == roadTeam);
 1414                    if (matchKey == null)
 415                    {
 416                        // Add existing bet values to maintain form state
 1417                        var existingHomeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1418                        var existingAwayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 419
 1420                        if (existingHomeInput != null && existingAwayInput != null &&
 1421                            !string.IsNullOrEmpty(existingHomeInput.Name) && !string.IsNullOrEmpty(existingAwayInput.Nam
 422                        {
 1423                            formData.Add(new KeyValuePair<string, string>(existingHomeInput.Name, existingHomeInput.Valu
 1424                            formData.Add(new KeyValuePair<string, string>(existingAwayInput.Name, existingAwayInput.Valu
 425                        }
 1426                        continue;
 427                    }
 428
 1429                    var prediction = bets[matchKey];
 430
 431                    // Find bet input fields in the row
 1432                    var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1433                    var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 434
 1435                    if (homeInput == null || awayInput == null)
 436                    {
 1437                        _logger.LogWarning("No betting inputs found for {MatchKey}, skipping", matchKey);
 1438                        continue;
 439                    }
 440
 441                    // Check if bets are already placed
 1442                    var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 1443                    var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 444
 1445                    if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBets)
 446                    {
 1447                        var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 1448                        _logger.LogInformation("{MatchKey} - skipped, already placed {ExistingBet}", matchKey, existingB
 1449                        betsSkipped++;
 450
 451                        // Keep existing values
 1452                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 453                        {
 1454                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, homeInput.Value ?? ""));
 1455                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, awayInput.Value ?? ""));
 456                        }
 1457                        continue;
 458                    }
 459
 460                    // Add bet to form data
 1461                    if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 462                    {
 1463                        formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString()));
 1464                        formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString()));
 1465                        betsPlaced++;
 1466                        _logger.LogInformation("{MatchKey} - betting {Prediction}", matchKey, prediction);
 467                    }
 468                    else
 469                    {
 0470                        _logger.LogWarning("{MatchKey} - input field names are missing, skipping", matchKey);
 471                        continue;
 472                    }
 1473                }
 0474                catch (Exception ex)
 475                {
 0476                    _logger.LogError(ex, "Error processing betting row");
 0477                    continue;
 478                }
 479            }
 480
 1481            _logger.LogInformation("Summary: {BetsPlaced} bets to place, {BetsSkipped} skipped", betsPlaced, betsSkipped
 482
 1483            if (betsPlaced == 0)
 484            {
 1485                _logger.LogInformation("No bets to place");
 1486                return true;
 487            }
 488
 489            // Find submit button
 1490            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 1491            var submitName = "submitbutton"; // Default from Python
 492
 1493            if (submitButton != null)
 494            {
 1495                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 496                {
 1497                    submitName = inputSubmit.Name;
 1498                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 499                }
 1500                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 501                {
 1502                    submitName = buttonSubmit.Name;
 1503                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 504                }
 505            }
 506            else
 507            {
 508                // Fallback to default submit button name
 1509                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 510            }
 511
 512            // Submit form
 1513            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 1514                (betForm.Action.StartsWith("http") ? betForm.Action :
 1515                 betForm.Action.StartsWith("/") ? betForm.Action :
 1516                 $"{community}/{betForm.Action}");
 517
 1518            var formContent = new FormUrlEncodedContent(formData);
 1519            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 520
 1521            if (submitResponse.IsSuccessStatusCode)
 522            {
 1523                _logger.LogInformation("✓ Successfully submitted {BetsPlaced} bets!", betsPlaced);
 1524                return true;
 525            }
 526            else
 527            {
 1528                _logger.LogError("✗ Failed to submit bets. Status: {StatusCode}", submitResponse.StatusCode);
 1529                return false;
 530            }
 531        }
 0532        catch (Exception ex)
 533        {
 0534            _logger.LogError(ex, "Exception during bet placement");
 0535            return false;
 536        }
 1537    }
 538
 539    /// <inheritdoc />
 540    public async Task<List<TeamStanding>> GetStandingsAsync(string community)
 541    {
 542        // Create cache key based on community
 1543        var cacheKey = $"standings_{community}";
 544
 545        // Try to get from cache first
 1546        if (_cache.TryGetValue(cacheKey, out List<TeamStanding>? cachedStandings))
 547        {
 1548            _logger.LogDebug("Retrieved standings for {Community} from cache", community);
 1549            return cachedStandings!;
 550        }
 551
 552        try
 553        {
 1554            var url = $"{community}/tabellen";
 1555            var response = await _httpClient.GetAsync(url);
 556
 1557            if (!response.IsSuccessStatusCode)
 558            {
 1559                _logger.LogError("Failed to fetch standings page. Status: {StatusCode}", response.StatusCode);
 1560                return new List<TeamStanding>();
 561            }
 562
 1563            var content = await response.Content.ReadAsStringAsync();
 1564            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 565
 1566            var standings = new List<TeamStanding>();
 567
 568            // Tournament pages can render one table per group; league pages render a single table.
 1569            var standingsTables = document.QuerySelectorAll("table.sporttabelle");
 1570            if (standingsTables.Length == 0)
 571            {
 1572                _logger.LogWarning("Could not find standings table");
 1573                return standings;
 574            }
 575
 1576            foreach (var standingsTable in standingsTables)
 577            {
 1578                var groupName = ExtractStandingsGroupName(standingsTable);
 1579                var tableBody = standingsTable.QuerySelector("tbody") ?? standingsTable;
 1580                var rows = tableBody.QuerySelectorAll("tr");
 1581                _logger.LogDebug("Found {RowCount} team rows in standings table for group {Group}", rows.Length, groupNa
 582
 1583                foreach (var row in rows)
 584                {
 585                    try
 586                    {
 1587                        var cells = row.QuerySelectorAll("td");
 1588                        if (cells.Length >= 9) // Need at least 9 columns for all data
 589                        {
 590                            // Extract data from table cells
 1591                            var positionText = cells[0].TextContent?.Trim().TrimEnd('.') ?? "";
 1592                            var teamNameElement = cells[1].QuerySelector("div") ?? cells[1].QuerySelector("a");
 1593                            var teamName = teamNameElement?.TextContent?.Trim() ?? cells[1].TextContent?.Trim() ?? "";
 1594                            var gamesPlayedText = cells[2].TextContent?.Trim() ?? "";
 1595                            var pointsText = cells[3].TextContent?.Trim() ?? "";
 1596                            var goalsText = cells[4].TextContent?.Trim() ?? "";
 1597                            var goalDifferenceText = cells[5].TextContent?.Trim() ?? "";
 1598                            var winsText = cells[6].TextContent?.Trim() ?? "";
 1599                            var drawsText = cells[7].TextContent?.Trim() ?? "";
 1600                            var lossesText = cells[8].TextContent?.Trim() ?? "";
 601
 602                            // Parse numeric values
 1603                            if (int.TryParse(positionText, out var position) &&
 1604                                int.TryParse(gamesPlayedText, out var gamesPlayed) &&
 1605                                int.TryParse(pointsText, out var points) &&
 1606                                int.TryParse(goalDifferenceText, out var goalDifference) &&
 1607                                int.TryParse(winsText, out var wins) &&
 1608                                int.TryParse(drawsText, out var draws) &&
 1609                                int.TryParse(lossesText, out var losses))
 610                            {
 611                                // Parse goals (format: "15:8")
 1612                                var goalsParts = goalsText.Split(':');
 1613                                var goalsFor = 0;
 1614                                var goalsAgainst = 0;
 615
 1616                                if (goalsParts.Length == 2)
 617                                {
 1618                                    int.TryParse(goalsParts[0], out goalsFor);
 1619                                    int.TryParse(goalsParts[1], out goalsAgainst);
 620                                }
 621
 1622                                var teamStanding = new TeamStanding(
 1623                                    position,
 1624                                    teamName,
 1625                                    gamesPlayed,
 1626                                    points,
 1627                                    goalsFor,
 1628                                    goalsAgainst,
 1629                                    goalDifference,
 1630                                    wins,
 1631                                    draws,
 1632                                    losses,
 1633                                    groupName);
 634
 1635                                standings.Add(teamStanding);
 1636                                _logger.LogDebug(
 1637                                    "Parsed team standing: {Position}. {TeamName} - {Points} points (group {Group})",
 1638                                    position,
 1639                                    teamName,
 1640                                    points,
 1641                                    groupName ?? "(none)");
 642                            }
 643                            else
 644                            {
 1645                                _logger.LogWarning("Failed to parse numeric values for team row");
 646                            }
 647                        }
 1648                    }
 0649                    catch (Exception ex)
 650                    {
 0651                        _logger.LogWarning(ex, "Error parsing standings row");
 0652                        continue;
 653                    }
 654                }
 655            }
 656
 1657            _logger.LogInformation("Successfully parsed {StandingsCount} team standings", standings.Count);
 658
 659            // Cache the results for 20 minutes (standings change relatively infrequently)
 1660            var cacheOptions = new MemoryCacheEntryOptions
 1661            {
 1662                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20),
 1663                SlidingExpiration = TimeSpan.FromMinutes(10) // Reset timer if accessed within 10 minutes
 1664            };
 1665            _cache.Set(cacheKey, standings, cacheOptions);
 1666            _logger.LogDebug("Cached standings for {Community} for 20 minutes", community);
 667
 1668            return standings;
 669        }
 0670        catch (Exception ex)
 671        {
 0672            _logger.LogError(ex, "Exception in GetStandingsAsync");
 0673            return new List<TeamStanding>();
 674        }
 1675    }
 676
 677    /// <inheritdoc />
 678    public Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community)
 679    {
 1680        return GetMatchesWithHistoryAsync(community, null);
 681    }
 682
 683    /// <inheritdoc />
 684    public Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community, int matchday)
 685    {
 1686        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
 1692        var cacheKey = matchday.HasValue
 1693            ? $"matches_history_{community}_{matchday.Value}"
 1694            : $"matches_history_{community}";
 695
 696        // Try to get from cache first
 1697        if (_cache.TryGetValue(cacheKey, out List<MatchWithHistory>? cachedMatches))
 698        {
 1699            _logger.LogDebug("Retrieved matches with history for {Community} from cache", community);
 1700            return cachedMatches!;
 701        }
 702
 703        try
 704        {
 1705            var matches = new List<MatchWithHistory>();
 706
 707            // First, get the tippabgabe page to find the link to spielinfos
 1708            var tippabgabeUrl = matchday.HasValue
 1709                ? $"{community}/tippabgabe?spieltagIndex={matchday.Value}"
 1710                : $"{community}/tippabgabe";
 1711            var response = await _httpClient.GetAsync(tippabgabeUrl);
 712
 1713            if (!response.IsSuccessStatusCode)
 714            {
 1715                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1716                return matches;
 717            }
 718
 1719            var content = await response.Content.ReadAsStringAsync();
 1720            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 721
 722            // Extract matchday from the tippabgabe page
 1723            var currentMatchday = ExtractMatchdayFromPage(document);
 1724            _logger.LogDebug("Extracted matchday for history extraction: {Matchday}", currentMatchday);
 1725            if (matchday.HasValue && currentMatchday != matchday.Value)
 726            {
 0727                _logger.LogWarning("Requested history matchday {RequestedMatchday}, but page displayed {DisplayedMatchda
 728            }
 729
 730            // Find the "Tippabgabe mit Spielinfos" link
 1731            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1732            if (spielinfoLink == null)
 733            {
 1734                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1735                return matches;
 736            }
 737
 1738            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1739            if (string.IsNullOrEmpty(spielinfoUrl))
 740            {
 0741                _logger.LogWarning("Spielinfo link has no href attribute");
 0742                return matches;
 743            }
 744
 745            // Make URL absolute if it's relative
 1746            if (spielinfoUrl.StartsWith("/"))
 747            {
 1748                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 749            }
 750
 1751            _logger.LogInformation("Starting to fetch match details from spielinfo pages...");
 752
 753            // Navigate through all matches using the right arrow navigation
 1754            var currentUrl = spielinfoUrl;
 1755            var matchCount = 0;
 756
 1757            while (!string.IsNullOrEmpty(currentUrl))
 758            {
 759                try
 760                {
 1761                    var spielinfoResponse = await _httpClient.GetAsync(currentUrl);
 1762                    if (!spielinfoResponse.IsSuccessStatusCode)
 763                    {
 1764                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", currentUrl, sp
 1765                        break;
 766                    }
 767
 1768                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1769                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 770
 771                    // Extract match information
 1772                    var matchWithHistory = ExtractMatchWithHistoryFromSpielinfoPage(spielinfoDocument, currentMatchday);
 1773                    if (matchWithHistory != null)
 774                    {
 1775                        matches.Add(matchWithHistory);
 1776                        matchCount++;
 1777                        _logger.LogDebug("Extracted match {Count}: {Match}", matchCount, matchWithHistory.Match);
 778                    }
 779
 780                    // Find the next match link (right arrow)
 1781                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1782                    if (nextLink != null)
 783                    {
 1784                        currentUrl = nextLink;
 1785                        if (currentUrl.StartsWith("/"))
 786                        {
 1787                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 788                        }
 789                    }
 790                    else
 791                    {
 792                        // No more matches
 1793                        break;
 794                    }
 1795                }
 0796                catch (Exception ex)
 797                {
 0798                    _logger.LogError(ex, "Error processing spielinfo page: {Url}", currentUrl);
 0799                    break;
 800                }
 801            }
 802
 1803            _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)
 1806            var cacheOptions = new MemoryCacheEntryOptions
 1807            {
 1808                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
 1809                SlidingExpiration = TimeSpan.FromMinutes(7) // Reset timer if accessed within 7 minutes
 1810            };
 1811            _cache.Set(cacheKey, matches, cacheOptions);
 1812            _logger.LogDebug("Cached matches with history for {Community} for 15 minutes", community);
 813
 1814            return matches;
 815        }
 0816        catch (Exception ex)
 817        {
 0818            _logger.LogError(ex, "Exception in GetMatchesWithHistoryAsync");
 0819            return new List<MatchWithHistory>();
 820        }
 1821    }
 822
 823    /// <inheritdoc />
 824    public async Task<int> GetCurrentTippuebersichtMatchdayAsync(string community)
 825    {
 1826        var document = await GetTippuebersichtDocumentAsync(community, null);
 1827        if (document == null)
 828        {
 1829            return 1;
 830        }
 831
 1832        return ExtractMatchdayFromPage(document);
 1833    }
 834
 835    /// <inheritdoc />
 836    public async Task<IReadOnlyList<CollectedMatchOutcome>> GetMatchdayOutcomesAsync(string community, int matchday)
 837    {
 1838        var cacheKey = $"tippuebersicht_outcomes_{community}_{matchday}";
 1839        if (_cache.TryGetValue(cacheKey, out IReadOnlyList<CollectedMatchOutcome>? cachedOutcomes))
 840        {
 1841            _logger.LogDebug("Retrieved tippuebersicht outcomes for {Community} matchday {Matchday} from cache", communi
 1842            return cachedOutcomes!;
 843        }
 844
 1845        var document = await GetTippuebersichtDocumentAsync(community, matchday);
 1846        if (document == null)
 847        {
 1848            return Array.Empty<CollectedMatchOutcome>();
 849        }
 850
 1851        var displayedMatchday = ExtractMatchdayFromPage(document);
 1852        if (displayedMatchday != matchday)
 853        {
 1854            _logger.LogWarning("Requested tippuebersicht matchday {RequestedMatchday}, but page displayed {DisplayedMatc
 855        }
 856
 1857        var outcomes = ParseTippuebersichtMatchdayOutcomes(document, displayedMatchday)
 1858            .AsReadOnly();
 859
 1860        var cacheOptions = new MemoryCacheEntryOptions
 1861        {
 1862            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
 1863            SlidingExpiration = TimeSpan.FromMinutes(5)
 1864        };
 865
 1866        _cache.Set(cacheKey, outcomes, cacheOptions);
 1867        return outcomes;
 1868    }
 869
 870    /// <inheritdoc />
 871    public async Task<KicktippCommunityMatchdaySnapshot?> GetCommunityMatchdaySnapshotAsync(string community, int matchd
 872    {
 1873        var cacheKey = $"tippuebersicht_snapshot_{community}_{matchday}";
 1874        if (_cache.TryGetValue(cacheKey, out KicktippCommunityMatchdaySnapshot? cachedSnapshot))
 875        {
 1876            _logger.LogDebug("Retrieved tippuebersicht snapshot for {Community} matchday {Matchday} from cache", communi
 1877            return cachedSnapshot;
 878        }
 879
 1880        var document = await GetTippuebersichtDocumentAsync(community, matchday);
 1881        if (document == null)
 882        {
 0883            return null;
 884        }
 885
 1886        var displayedMatchday = ExtractMatchdayFromPage(document);
 1887        if (displayedMatchday != matchday)
 888        {
 0889            _logger.LogWarning("Requested tippuebersicht snapshot matchday {RequestedMatchday}, but page displayed {Disp
 890        }
 891
 1892        var outcomes = ParseTippuebersichtMatchdayOutcomes(document, displayedMatchday)
 1893            .AsReadOnly();
 1894        var participants = ParseTippuebersichtParticipantSnapshots(document, displayedMatchday, outcomes)
 1895            .AsReadOnly();
 896
 1897        var snapshot = new KicktippCommunityMatchdaySnapshot(displayedMatchday, outcomes, participants);
 1898        var cacheOptions = new MemoryCacheEntryOptions
 1899        {
 1900            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
 1901            SlidingExpiration = TimeSpan.FromMinutes(5)
 1902        };
 903
 1904        _cache.Set(cacheKey, snapshot, cacheOptions);
 1905        return snapshot;
 1906    }
 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
 1914            var tippabgabeUrl = $"{community}/tippabgabe";
 1915            var response = await _httpClient.GetAsync(tippabgabeUrl);
 916
 1917            if (!response.IsSuccessStatusCode)
 918            {
 1919                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1920                return (new List<MatchResult>(), new List<MatchResult>());
 921            }
 922
 1923            var content = await response.Content.ReadAsStringAsync();
 1924            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 925
 926            // Find the "Tippabgabe mit Spielinfos" link
 1927            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1928            if (spielinfoLink == null)
 929            {
 1930                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1931                return (new List<MatchResult>(), new List<MatchResult>());
 932            }
 933
 1934            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1935            if (string.IsNullOrEmpty(spielinfoUrl))
 936            {
 0937                _logger.LogWarning("Spielinfo link has no href attribute");
 0938                return (new List<MatchResult>(), new List<MatchResult>());
 939            }
 940
 941            // Make URL absolute if it's relative
 1942            if (spielinfoUrl.StartsWith("/"))
 943            {
 1944                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 945            }
 946
 947            // Navigate through all matches using the right arrow navigation
 1948            var currentUrl = spielinfoUrl;
 949
 1950            while (!string.IsNullOrEmpty(currentUrl))
 951            {
 952                try
 953                {
 954                    // Add ansicht=2 parameter for home/away history
 1955                    var homeAwayUrl = currentUrl.Contains('?')
 1956                        ? $"{currentUrl}&ansicht=2"
 1957                        : $"{currentUrl}?ansicht=2";
 958
 1959                    var spielinfoResponse = await _httpClient.GetAsync(homeAwayUrl);
 1960                    if (!spielinfoResponse.IsSuccessStatusCode)
 961                    {
 1962                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", homeAwayUrl, s
 1963                        break;
 964                    }
 965
 1966                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1967                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 968
 969                    // Check if this page contains our match
 1970                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 971                    {
 972                        // Extract home team home history
 1973                        var homeTeamHomeHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoHeim");
 974
 975                        // Extract away team away history
 1976                        var awayTeamAwayHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoGast");
 977
 1978                        return (homeTeamHomeHistory, awayTeamAwayHistory);
 979                    }
 980
 981                    // Find the next match link (right arrow)
 1982                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1983                    if (nextLink != null)
 984                    {
 1985                        currentUrl = nextLink;
 1986                        if (currentUrl.StartsWith("/"))
 987                        {
 1988                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 989                        }
 990                    }
 991                    else
 992                    {
 993                        // No more matches
 1994                        break;
 995                    }
 1996                }
 0997                catch (Exception ex)
 998                {
 0999                    _logger.LogError(ex, "Error processing spielinfo page for home/away history: {CurrentUrl}", currentU
 01000                    break;
 1001                }
 1002            }
 1003
 11004            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 11005            return (new List<MatchResult>(), new List<MatchResult>());
 1006        }
 01007        catch (Exception ex)
 1008        {
 01009            _logger.LogError(ex, "Exception in GetHomeAwayHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 01010            return (new List<MatchResult>(), new List<MatchResult>());
 1011        }
 11012    }
 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
 11020            var tippabgabeUrl = $"{community}/tippabgabe";
 11021            var response = await _httpClient.GetAsync(tippabgabeUrl);
 1022
 11023            if (!response.IsSuccessStatusCode)
 1024            {
 11025                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 11026                return new List<MatchResult>();
 1027            }
 1028
 11029            var content = await response.Content.ReadAsStringAsync();
 11030            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1031
 1032            // Find the "Tippabgabe mit Spielinfos" link
 11033            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 11034            if (spielinfoLink == null)
 1035            {
 11036                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 11037                return new List<MatchResult>();
 1038            }
 1039
 11040            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 11041            if (string.IsNullOrEmpty(spielinfoUrl))
 1042            {
 01043                _logger.LogWarning("Spielinfo link has no href attribute");
 01044                return new List<MatchResult>();
 1045            }
 1046
 1047            // Make URL absolute if it's relative
 11048            if (spielinfoUrl.StartsWith("/"))
 1049            {
 11050                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 1051            }
 1052
 1053            // Navigate through all matches using the right arrow navigation
 11054            var currentUrl = spielinfoUrl;
 1055
 11056            while (!string.IsNullOrEmpty(currentUrl))
 1057            {
 1058                try
 1059                {
 1060                    // Add ansicht=3 parameter for head-to-head history
 11061                    var headToHeadUrl = currentUrl.Contains('?')
 11062                        ? $"{currentUrl}&ansicht=3"
 11063                        : $"{currentUrl}?ansicht=3";
 1064
 11065                    var spielinfoResponse = await _httpClient.GetAsync(headToHeadUrl);
 11066                    if (!spielinfoResponse.IsSuccessStatusCode)
 1067                    {
 11068                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", headToHeadUrl,
 11069                        break;
 1070                    }
 1071
 11072                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 11073                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1074
 1075                    // Check if this page contains our match
 11076                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1077                    {
 1078                        // Extract head-to-head history
 11079                        return ExtractTeamHistory(spielinfoDocument, "spielinfoDirekterVergleich");
 1080                    }
 1081
 1082                    // Find the next match link (right arrow)
 11083                    var nextLink = FindNextMatchLink(spielinfoDocument);
 11084                    if (nextLink != null)
 1085                    {
 11086                        currentUrl = nextLink;
 11087                        if (currentUrl.StartsWith("/"))
 1088                        {
 11089                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1090                        }
 1091                    }
 1092                    else
 1093                    {
 1094                        // No more matches
 11095                        break;
 1096                    }
 11097                }
 01098                catch (Exception ex)
 1099                {
 01100                    _logger.LogError(ex, "Error processing spielinfo page for head-to-head history: {CurrentUrl}", curre
 01101                    break;
 1102                }
 1103            }
 1104
 11105            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 11106            return new List<MatchResult>();
 1107        }
 01108        catch (Exception ex)
 1109        {
 01110            _logger.LogError(ex, "Exception in GetHeadToHeadHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTe
 01111            return new List<MatchResult>();
 1112        }
 11113    }
 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
 11121            var tippabgabeUrl = $"{community}/tippabgabe";
 11122            var response = await _httpClient.GetAsync(tippabgabeUrl);
 1123
 11124            if (!response.IsSuccessStatusCode)
 1125            {
 11126                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 11127                return new List<HeadToHeadResult>();
 1128            }
 1129
 11130            var content = await response.Content.ReadAsStringAsync();
 11131            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1132
 1133            // Find the "Tippabgabe mit Spielinfos" link
 11134            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 11135            if (spielinfoLink == null)
 1136            {
 11137                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 11138                return new List<HeadToHeadResult>();
 1139            }
 1140
 11141            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 11142            if (string.IsNullOrEmpty(spielinfoUrl))
 1143            {
 01144                _logger.LogWarning("Spielinfo link has no href attribute");
 01145                return new List<HeadToHeadResult>();
 1146            }
 1147
 1148            // Make URL absolute if it's relative
 11149            if (spielinfoUrl.StartsWith("/"))
 1150            {
 11151                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 1152            }
 1153
 1154            // Navigate through all matches using the right arrow navigation
 11155            var currentUrl = spielinfoUrl;
 1156
 11157            while (!string.IsNullOrEmpty(currentUrl))
 1158            {
 1159                try
 1160                {
 1161                    // Append ansicht=3 to get head-to-head view
 11162                    var urlWithAnsicht = currentUrl.Contains('?') ? $"{currentUrl}&ansicht=3" : $"{currentUrl}?ansicht=3
 11163                    var spielinfoResponse = await _httpClient.GetAsync(urlWithAnsicht);
 1164
 11165                    if (!spielinfoResponse.IsSuccessStatusCode)
 1166                    {
 11167                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", urlWithAnsicht
 11168                        break;
 1169                    }
 1170
 11171                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 11172                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1173
 1174                    // Check if this page contains our match
 11175                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1176                    {
 1177                        // Extract head-to-head history from this page
 11178                        return ExtractHeadToHeadHistory(spielinfoDocument);
 1179                    }
 1180
 1181                    // Find the next match link (right arrow)
 11182                    var nextLink = FindNextMatchLink(spielinfoDocument);
 11183                    if (nextLink != null)
 1184                    {
 11185                        currentUrl = nextLink;
 11186                        if (currentUrl.StartsWith("/"))
 1187                        {
 11188                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1189                        }
 1190                    }
 1191                    else
 1192                    {
 11193                        break;
 1194                    }
 11195                }
 01196                catch (Exception ex)
 1197                {
 01198                    _logger.LogWarning(ex, "Error processing spielinfo page: {Url}", currentUrl);
 01199                    break;
 1200                }
 1201            }
 1202
 11203            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 11204            return new List<HeadToHeadResult>();
 1205        }
 01206        catch (Exception ex)
 1207        {
 01208            _logger.LogError(ex, "Exception in GetHeadToHeadDetailedHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam
 01209            return new List<HeadToHeadResult>();
 1210        }
 11211    }
 1212    private bool IsMatchOnPage(IDocument document, string homeTeam, string awayTeam)
 1213    {
 1214        try
 1215        {
 1216            // Look for the match in the tippabgabe table
 11217            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 1218
 11219            foreach (var row in matchRows)
 1220            {
 11221                var cells = row.QuerySelectorAll("td");
 11222                if (cells.Length >= 3)
 1223                {
 11224                    var pageHomeTeam = cells[1].TextContent?.Trim() ?? "";
 11225                    var pageAwayTeam = cells[2].TextContent?.Trim() ?? "";
 1226
 11227                    if (pageHomeTeam == homeTeam && pageAwayTeam == awayTeam)
 1228                    {
 11229                        return true;
 1230                    }
 1231                }
 1232            }
 1233
 11234            return false;
 1235        }
 01236        catch (Exception ex)
 1237        {
 01238            _logger.LogDebug(ex, "Error checking if match is on page");
 01239            return false;
 1240        }
 11241    }
 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
 11249            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 11250            if (matchRows.Length == 0)
 1251            {
 01252                _logger.LogWarning("Could not find any match rows in tippabgabe table on spielinfo page");
 01253                return null;
 1254            }
 1255
 11256            _logger.LogDebug("Found {RowCount} rows in tippabgabe table", matchRows.Length);
 1257
 1258            // Find the row that contains match data (has input fields for betting)
 11259            IElement? matchRow = null;
 11260            foreach (var row in matchRows)
 1261            {
 11262                var rowCells = row.QuerySelectorAll("td");
 11263                if (rowCells.Length >= 4)
 1264                {
 1265                    // Check if this row has betting inputs (indicates it's the match row)
 11266                    var bettingInputs = rowCells[3].QuerySelectorAll("input[type='text']");
 11267                    if (bettingInputs.Length >= 2)
 1268                    {
 11269                        matchRow = row;
 11270                        break;
 1271                    }
 1272                }
 1273            }
 1274
 11275            if (matchRow == null)
 1276            {
 11277                _logger.LogWarning("Could not find match row with betting inputs in tippabgabe table");
 11278                return null;
 1279            }
 1280
 11281            var cells = matchRow.QuerySelectorAll("td");
 11282            if (cells.Length < 4)
 1283            {
 01284                _logger.LogWarning("Match row does not have enough cells");
 01285                return null;
 1286            }
 1287
 11288            _logger.LogDebug("Found {CellCount} cells in match row", cells.Length);
 11289            for (int i = 0; i < Math.Min(cells.Length, 5); i++)
 1290            {
 11291                _logger.LogDebug("Cell[{Index}]: '{Content}' (Class: '{Class}')", i, cells[i].TextContent?.Trim(), cells
 1292            }
 1293
 11294            var timeText = cells[0].TextContent?.Trim() ?? "";
 11295            var homeTeam = cells[1].TextContent?.Trim() ?? "";
 11296            var awayTeam = cells[2].TextContent?.Trim() ?? "";
 1297
 11298            _logger.LogDebug("Extracted from spielinfo page - Time: '{TimeText}', Home: '{HomeTeam}', Away: '{AwayTeam}'
 1299
 11300            if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(awayTeam))
 1301            {
 01302                _logger.LogWarning("Could not extract team names from match table");
 01303                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
 11308            var isCancelled = IsCancelledTimeText(timeText);
 11309            if (isCancelled)
 1310            {
 01311                _logger.LogWarning(
 01312                    "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt) on spielinfo page. " +
 01313                    "Using current time as fallback since spielinfo doesn't provide time inheritance context.",
 01314                    homeTeam, awayTeam);
 1315            }
 1316
 11317            var startsAt = ParseMatchDateTime(timeText);
 11318            var match = new Match(homeTeam, awayTeam, startsAt, matchday, isCancelled);
 1319
 1320            // Extract home team history
 11321            var homeTeamHistory = ExtractTeamHistory(document, "spielinfoHeim");
 1322
 1323            // Extract away team history
 11324            var awayTeamHistory = ExtractTeamHistory(document, "spielinfoGast");
 1325
 11326            return new MatchWithHistory(match, homeTeamHistory, awayTeamHistory);
 1327        }
 01328        catch (Exception ex)
 1329        {
 01330            _logger.LogError(ex, "Error extracting match with history from spielinfo page");
 01331            return null;
 1332        }
 11333    }
 1334
 1335    private List<MatchResult> ExtractTeamHistory(IDocument document, string tableClass)
 1336    {
 11337        var results = new List<MatchResult>();
 1338
 1339        try
 1340        {
 11341            var table = document.QuerySelector($"table.{tableClass} tbody");
 11342            if (table == null)
 1343            {
 01344                _logger.LogDebug("Could not find team history table with class: {TableClass}", tableClass);
 01345                return results;
 1346            }
 1347
 11348            var rows = table.QuerySelectorAll("tr");
 11349            foreach (var row in rows)
 1350            {
 1351                try
 1352                {
 11353                    var cells = row.QuerySelectorAll("td");
 1354
 1355                    // Handle different table formats
 1356                    string competition, homeTeam, awayTeam;
 11357                    var resultCell = cells.Last(); // Result is always in the last cell
 11358                    var homeGoals = (int?)null;
 11359                    var awayGoals = (int?)null;
 11360                    var outcome = MatchOutcome.Pending;
 11361                    string? annotation = null;
 1362
 11363                    if (tableClass == "spielinfoDirekterVergleich")
 1364                    {
 1365                        // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 11366                        if (cells.Length < 6)
 01367                            continue;
 1368
 11369                        competition = $"{cells[0].TextContent?.Trim()} {cells[1].TextContent?.Trim()}";
 11370                        homeTeam = cells[3].TextContent?.Trim() ?? "";
 11371                        awayTeam = cells[4].TextContent?.Trim() ?? "";
 1372                    }
 1373                    else
 1374                    {
 1375                        // Standard format: Competition | Home | Away | Result
 11376                        if (cells.Length < 4)
 01377                            continue;
 1378
 11379                        competition = cells[0].TextContent?.Trim() ?? "";
 11380                        homeTeam = cells[1].TextContent?.Trim() ?? "";
 11381                        awayTeam = cells[2].TextContent?.Trim() ?? "";
 1382                    }
 1383
 1384                    // Parse the score from the result cell
 11385                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 11386                    if (scoreElements.Length >= 2)
 1387                    {
 11388                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 11389                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1390
 11391                        if (homeScoreText != "-" && awayScoreText != "-")
 1392                        {
 11393                            if (int.TryParse(homeScoreText, out var homeScore) && int.TryParse(awayScoreText, out var aw
 1394                            {
 11395                                homeGoals = homeScore;
 11396                                awayGoals = awayScore;
 1397
 1398                                // Determine outcome from team's perspective based on CSS classes
 11399                                var homeTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[3] : cells[1];
 11400                                var awayTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[4] : cells[2];
 1401
 11402                                var isHomeTeam = homeTeamCell.ClassList.Contains("sieg") || homeTeamCell.ClassList.Conta
 11403                                var isAwayTeam = awayTeamCell.ClassList.Contains("sieg") || awayTeamCell.ClassList.Conta
 1404
 11405                                if (isHomeTeam)
 1406                                {
 11407                                    outcome = homeScore > awayScore ? MatchOutcome.Win :
 11408                                             homeScore < awayScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1409                                }
 11410                                else if (isAwayTeam)
 1411                                {
 11412                                    outcome = awayScore > homeScore ? MatchOutcome.Win :
 11413                                             awayScore < homeScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1414                                }
 1415                                else
 1416                                {
 1417                                    // Fallback: determine from score (neutral perspective)
 11418                                    outcome = homeScore == awayScore ? MatchOutcome.Draw :
 11419                                             homeScore > awayScore ? MatchOutcome.Win : MatchOutcome.Loss;
 1420                                }
 1421                            }
 1422                        }
 1423                    }
 1424
 1425                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 11426                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 11427                    if (annotationElement != null)
 1428                    {
 11429                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1430                    }
 1431
 11432                    var matchResult = new MatchResult(competition, homeTeam, awayTeam, homeGoals, awayGoals, outcome, an
 11433                    results.Add(matchResult);
 11434                }
 01435                catch (Exception ex)
 1436                {
 01437                    _logger.LogDebug(ex, "Error parsing team history row");
 01438                    continue;
 1439                }
 1440            }
 11441        }
 01442        catch (Exception ex)
 1443        {
 01444            _logger.LogError(ex, "Error extracting team history for table class: {TableClass}", tableClass);
 01445        }
 1446
 11447        return results;
 01448    }
 1449
 1450    private List<HeadToHeadResult> ExtractHeadToHeadHistory(IDocument document)
 1451    {
 11452        var results = new List<HeadToHeadResult>();
 1453
 1454        try
 1455        {
 11456            var table = document.QuerySelector("table.spielinfoDirekterVergleich tbody");
 11457            if (table == null)
 1458            {
 11459                _logger.LogDebug("Could not find head-to-head table with class: spielinfoDirekterVergleich");
 11460                return results;
 1461            }
 1462
 11463            var rows = table.QuerySelectorAll("tr");
 11464            foreach (var row in rows)
 1465            {
 1466                try
 1467                {
 11468                    var cells = row.QuerySelectorAll("td");
 1469
 1470                    // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 11471                    if (cells.Length < 6)
 11472                        continue;
 1473
 11474                    var league = cells[0].TextContent?.Trim() ?? "";
 11475                    var matchday = cells[1].TextContent?.Trim() ?? "";
 11476                    var playedAt = cells[2].TextContent?.Trim() ?? "";
 11477                    var homeTeam = cells[3].TextContent?.Trim() ?? "";
 11478                    var awayTeam = cells[4].TextContent?.Trim() ?? "";
 1479
 1480                    // Extract score from the result cell
 11481                    var resultCell = cells[5];
 11482                    var score = "";
 11483                    string? annotation = null;
 1484
 11485                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 11486                    if (scoreElements.Length >= 2)
 1487                    {
 11488                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 11489                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1490
 11491                        if (homeScoreText != "-" && awayScoreText != "-")
 1492                        {
 11493                            score = $"{homeScoreText}:{awayScoreText}";
 1494                        }
 1495                    }
 1496
 1497                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 11498                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 11499                    if (annotationElement != null)
 1500                    {
 11501                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1502                    }
 1503
 11504                    var headToHeadResult = new HeadToHeadResult(league, matchday, playedAt, homeTeam, awayTeam, score, a
 11505                    results.Add(headToHeadResult);
 11506                }
 01507                catch (Exception ex)
 1508                {
 01509                    _logger.LogDebug(ex, "Error parsing head-to-head row");
 01510                    continue;
 1511                }
 1512            }
 11513        }
 01514        catch (Exception ex)
 1515        {
 01516            _logger.LogError(ex, "Error extracting head-to-head history");
 01517        }
 1518
 11519        return results;
 11520    }
 1521
 1522    private string? FindNextMatchLink(IDocument document)
 1523    {
 1524        try
 1525        {
 1526            // Look for the right arrow button in the match navigation
 11527            var nextButton = document.QuerySelector(".prevnextNext a");
 11528            if (nextButton == null)
 1529            {
 11530                _logger.LogDebug("No next match button found");
 11531                return null;
 1532            }
 1533
 1534            // Check if the button is disabled
 11535            var parentDiv = nextButton.ParentElement;
 11536            if (parentDiv?.ClassList.Contains("disabled") == true)
 1537            {
 11538                _logger.LogDebug("Next match button is disabled - reached end of matches");
 11539                return null;
 1540            }
 1541
 11542            var href = nextButton.GetAttribute("href");
 11543            if (string.IsNullOrEmpty(href))
 1544            {
 01545                _logger.LogDebug("Next match button has no href");
 01546                return null;
 1547            }
 1548
 11549            _logger.LogDebug("Found next match link: {Href}", href);
 11550            return href;
 1551        }
 01552        catch (Exception ex)
 1553        {
 01554            _logger.LogError(ex, "Error finding next match link");
 01555            return null;
 1556        }
 11557    }
 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
 11566            if (string.IsNullOrWhiteSpace(timeText))
 1567            {
 11568                _logger.LogWarning("Match time text is empty, using MinValue for database consistency");
 11569                return DateTimeOffset.MinValue.ToZonedDateTime();
 1570            }
 1571
 1572            // Expected formats: "22.08.25 20:30" and "22.08.2026 20:30".
 11573            _logger.LogDebug("Attempting to parse time: '{TimeText}'", timeText);
 11574            var formats = new[] { "dd.MM.yy HH:mm", "dd.MM.yyyy HH:mm" };
 11575            if (DateTime.TryParseExact(timeText, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dat
 1576            {
 11577                _logger.LogDebug("Successfully parsed time: {DateTime}", dateTime);
 11578                var localDateTime = LocalDateTime.FromDateTime(DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified))
 11579                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
 11585            _logger.LogWarning("Could not parse match time: '{TimeText}', using MinValue for database consistency", time
 11586            return DateTimeOffset.MinValue.ToZonedDateTime();
 1587        }
 01588        catch (Exception ex)
 1589        {
 01590            _logger.LogError(ex, "Error parsing match time '{TimeText}'", timeText);
 01591            return DateTimeOffset.MinValue.ToZonedDateTime();
 1592        }
 11593    }
 1594
 1595    private static string? ExtractStandingsGroupName(IElement standingsTable)
 1596    {
 11597        var caption = ExtractGroupLabel(standingsTable.QuerySelector("caption")?.TextContent);
 11598        if (!string.IsNullOrWhiteSpace(caption))
 1599        {
 01600            return caption;
 1601        }
 1602
 11603        foreach (var headerCell in standingsTable.QuerySelectorAll("thead th, tr th"))
 1604        {
 11605            var headerLabel = ExtractGroupLabel(headerCell.TextContent);
 11606            if (!string.IsNullOrWhiteSpace(headerLabel))
 1607            {
 11608                return headerLabel;
 1609            }
 1610        }
 1611
 11612        for (var current = standingsTable; current is not null; current = current.ParentElement)
 1613        {
 11614            var labelFromPreviousSibling = ExtractGroupLabelFromPreviousSiblings(current);
 11615            if (!string.IsNullOrWhiteSpace(labelFromPreviousSibling))
 1616            {
 11617                return labelFromPreviousSibling;
 1618            }
 1619
 11620            if (current != standingsTable && ContainsOnlyCurrentStandingsTable(current, standingsTable))
 1621            {
 11622                var labelFromWrapper = ExtractGroupLabel(current.TextContent);
 11623                if (!string.IsNullOrWhiteSpace(labelFromWrapper))
 1624                {
 01625                    return labelFromWrapper;
 1626                }
 1627            }
 1628
 11629            if (current.TagName.Equals("BODY", StringComparison.OrdinalIgnoreCase))
 1630            {
 1631                break;
 1632            }
 1633        }
 1634
 11635        return null;
 11636    }
 1637
 1638    private static string? ExtractGroupLabelFromPreviousSiblings(IElement element)
 1639    {
 11640        for (var sibling = element.PreviousElementSibling; sibling is not null; sibling = sibling.PreviousElementSibling
 1641        {
 11642            if (IsStandingsTableContainer(sibling))
 1643            {
 11644                foreach (var previousHeading in sibling.QuerySelectorAll("h1,h2,h3,h4,h5,h6").Reverse())
 1645                {
 11646                    var headingLabel = ExtractGroupLabel(previousHeading.TextContent);
 11647                    if (!string.IsNullOrWhiteSpace(headingLabel))
 1648                    {
 11649                        return headingLabel;
 1650                    }
 1651                }
 1652
 1653                break;
 1654            }
 1655
 11656            var heading = IsHeading(sibling)
 11657                ? sibling
 11658                : sibling.QuerySelector("h1,h2,h3,h4,h5,h6");
 11659            var label = ExtractGroupLabel(heading?.TextContent);
 11660            if (!string.IsNullOrWhiteSpace(label))
 1661            {
 11662                return label;
 1663            }
 1664
 11665            label = ExtractGroupLabel(sibling.TextContent);
 11666            if (!string.IsNullOrWhiteSpace(label))
 1667            {
 01668                return label;
 1669            }
 1670        }
 1671
 11672        return null;
 11673    }
 1674
 1675    private static bool ContainsOnlyCurrentStandingsTable(IElement candidate, IElement standingsTable)
 1676    {
 11677        var nestedStandingsTables = candidate.QuerySelectorAll("table.sporttabelle");
 11678        return nestedStandingsTables.Length == 1 && ReferenceEquals(nestedStandingsTables[0], standingsTable);
 1679    }
 1680
 1681    private static bool IsStandingsTableContainer(IElement element)
 1682    {
 11683        return element.Matches("table.sporttabelle") || element.QuerySelector("table.sporttabelle") is not null;
 1684    }
 1685
 1686    private static bool IsHeading(IElement element)
 1687    {
 11688        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    {
 11693        var normalized = NormalizeWhitespace(text);
 11694        if (string.IsNullOrWhiteSpace(normalized))
 1695        {
 11696            return null;
 1697        }
 1698
 11699        var match = Regex.Match(
 11700            normalized,
 11701            @"\b(?<prefix>Gruppe|Group)\s+(?<group>[A-Z])",
 11702            System.Text.RegularExpressions.RegexOptions.IgnoreCase);
 11703        if (!match.Success)
 1704        {
 11705            return null;
 1706        }
 1707
 11708        var prefix = match.Groups["prefix"].Value.Equals("group", StringComparison.OrdinalIgnoreCase)
 11709            ? "Group"
 11710            : "Gruppe";
 11711        return $"{prefix} {match.Groups["group"].Value.ToUpperInvariant()}";
 1712    }
 1713
 1714    private static string NormalizeWhitespace(string? value)
 1715    {
 11716        return string.IsNullOrWhiteSpace(value)
 11717            ? string.Empty
 11718            : 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    {
 11742        return string.Equals(timeText, "Abgesagt", StringComparison.OrdinalIgnoreCase);
 1743    }
 1744
 1745    private async Task<IDocument?> GetTippuebersichtDocumentAsync(string community, int? matchday)
 1746    {
 1747        try
 1748        {
 11749            var url = matchday.HasValue
 11750                ? $"{community}/tippuebersicht?spieltagIndex={matchday.Value}"
 11751                : $"{community}/tippuebersicht";
 1752
 11753            var response = await _httpClient.GetAsync(url);
 11754            if (!response.IsSuccessStatusCode)
 1755            {
 11756                _logger.LogError("Failed to fetch tippuebersicht page {Url}. Status: {StatusCode}", url, response.Status
 11757                return null;
 1758            }
 1759
 11760            var content = await response.Content.ReadAsStringAsync();
 11761            return await _browsingContext.OpenAsync(req => req.Content(content));
 1762        }
 01763        catch (Exception ex)
 1764        {
 01765            _logger.LogError(ex, "Error fetching tippuebersicht page for {Community} matchday {Matchday}", community, ma
 01766            return null;
 1767        }
 11768    }
 1769
 1770    private List<CollectedMatchOutcome> ParseTippuebersichtMatchdayOutcomes(IDocument document, int matchday)
 1771    {
 11772        var outcomes = new List<CollectedMatchOutcome>();
 1773
 11774        var matchTable = document.QuerySelector("#spielplanSpiele tbody");
 11775        if (matchTable == null)
 1776        {
 11777            _logger.LogWarning("Could not find tippuebersicht match table for matchday {Matchday}", matchday);
 11778            return outcomes;
 1779        }
 1780
 11781        var matchRows = matchTable.QuerySelectorAll("tr");
 11782        string lastValidTimeText = string.Empty;
 1783
 11784        foreach (var row in matchRows)
 1785        {
 1786            try
 1787            {
 11788                var cells = row.QuerySelectorAll("td");
 11789                if (cells.Length < 4)
 1790                {
 11791                    continue;
 1792                }
 1793
 11794                var timeText = cells[0].TextContent?.Trim() ?? string.Empty;
 11795                var homeTeam = cells[1].TextContent?.Trim() ?? string.Empty;
 11796                var awayTeam = cells[2].TextContent?.Trim() ?? string.Empty;
 1797
 11798                if (string.IsNullOrWhiteSpace(homeTeam) || string.IsNullOrWhiteSpace(awayTeam))
 1799                {
 11800                    continue;
 1801                }
 1802
 11803                var isCancelled = IsCancelledTimeText(timeText);
 11804                if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 1805                {
 11806                    if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 1807                    {
 11808                        timeText = lastValidTimeText;
 1809                    }
 1810                }
 1811                else
 1812                {
 11813                    lastValidTimeText = timeText;
 1814                }
 1815
 11816                var startsAt = ParseMatchDateTime(timeText);
 11817                var (homeGoals, awayGoals, availability) = ParseMatchOutcome(cells[3]);
 11818                var tippSpielId = ExtractTippSpielId(row.GetAttribute("data-url"));
 1819
 11820                outcomes.Add(new CollectedMatchOutcome(
 11821                    homeTeam,
 11822                    awayTeam,
 11823                    startsAt,
 11824                    matchday,
 11825                    homeGoals,
 11826                    awayGoals,
 11827                    availability,
 11828                    tippSpielId));
 11829            }
 01830            catch (Exception ex)
 1831            {
 01832                _logger.LogWarning(ex, "Error parsing tippuebersicht row for matchday {Matchday}", matchday);
 01833            }
 1834        }
 1835
 11836        _logger.LogInformation("Parsed {MatchCount} tippuebersicht matches for matchday {Matchday}", outcomes.Count, mat
 11837        return outcomes;
 1838    }
 1839
 1840    private List<KicktippCommunityParticipantSnapshot> ParseTippuebersichtParticipantSnapshots(
 1841        IDocument document,
 1842        int matchday,
 1843        IReadOnlyList<CollectedMatchOutcome> outcomes)
 1844    {
 11845        var rankingTable = document.QuerySelector("#ranking");
 11846        if (rankingTable == null)
 1847        {
 01848            _logger.LogWarning("Could not find tippuebersicht ranking table for matchday {Matchday}", matchday);
 01849            return [];
 1850        }
 1851
 11852        var completedMappings = BuildCompletedRankingEventMappings(rankingTable, outcomes);
 11853        if (completedMappings.Count == 0)
 1854        {
 01855            _logger.LogInformation("No completed ranking event mappings found for matchday {Matchday}", matchday);
 01856            return [];
 1857        }
 1858
 11859        var participantRows = rankingTable.QuerySelectorAll("tbody tr.teilnehmer");
 11860        var participants = new List<KicktippCommunityParticipantSnapshot>();
 1861
 11862        foreach (var row in participantRows)
 1863        {
 1864            try
 1865            {
 11866                var participantId = row.GetAttribute("data-teilnehmer-id")?.Trim() ?? string.Empty;
 11867                var displayName = row.QuerySelector(".mg_name")?.TextContent?.Trim() ?? string.Empty;
 11868                if (string.IsNullOrWhiteSpace(participantId) || string.IsNullOrWhiteSpace(displayName))
 1869                {
 01870                    continue;
 1871                }
 1872
 11873                var predictions = new List<KicktippCommunityMatchPrediction>();
 11874                foreach (var mapping in completedMappings.OrderBy(candidate => candidate.EventIndex))
 1875                {
 11876                    var predictionCell = row.QuerySelector($"td.ereignis{mapping.EventIndex}");
 11877                    if (predictionCell == null)
 1878                    {
 1879                        continue;
 1880                    }
 1881
 11882                    predictions.Add(ParseParticipantPredictionCell(predictionCell, mapping));
 1883                }
 1884
 11885                participants.Add(new KicktippCommunityParticipantSnapshot(
 11886                    participantId,
 11887                    displayName,
 11888                    predictions,
 11889                    ParseIntegerCell(row.QuerySelector("td.spieltagspunkte")),
 11890                    ParseIntegerCell(row.QuerySelector("td.gesamtpunkte"))));
 11891            }
 01892            catch (Exception ex)
 1893            {
 01894                _logger.LogWarning(ex, "Error parsing tippuebersicht participant row for matchday {Matchday}", matchday)
 01895            }
 1896        }
 1897
 11898        _logger.LogInformation("Parsed {ParticipantCount} tippuebersicht participants for matchday {Matchday}", particip
 11899        return participants;
 1900    }
 1901
 1902    private static (int? homeGoals, int? awayGoals, MatchOutcomeAvailability availability) ParseMatchOutcome(IElement re
 1903    {
 11904        var homeGoalText = resultCell.QuerySelector(".kicktipp-heim")?.TextContent?.Trim();
 11905        var awayGoalText = resultCell.QuerySelector(".kicktipp-gast")?.TextContent?.Trim();
 1906
 11907        if (int.TryParse(homeGoalText, out var homeGoals) && int.TryParse(awayGoalText, out var awayGoals))
 1908        {
 11909            return (homeGoals, awayGoals, MatchOutcomeAvailability.Completed);
 1910        }
 1911
 11912        return (null, null, MatchOutcomeAvailability.Pending);
 1913    }
 1914
 1915    private static string? ExtractTippSpielId(string? dataUrl)
 1916    {
 11917        if (string.IsNullOrWhiteSpace(dataUrl))
 1918        {
 11919            return null;
 1920        }
 1921
 11922        var match = Regex.Match(dataUrl, @"(?:\?|&)tippspielId=(\d+)");
 11923        return match.Success ? match.Groups[1].Value : null;
 1924    }
 1925
 1926    private List<CompletedRankingEventMapping> BuildCompletedRankingEventMappings(
 1927        IElement rankingTable,
 1928        IReadOnlyList<CollectedMatchOutcome> outcomes)
 1929    {
 11930        var outcomesByTippSpielId = outcomes
 11931            .Where(outcome => !string.IsNullOrWhiteSpace(outcome.TippSpielId))
 11932            .ToDictionary(outcome => outcome.TippSpielId!, StringComparer.Ordinal);
 11933        var outcomesByEventIndex = outcomes
 11934            .Select((outcome, index) => new { outcome, index })
 11935            .ToDictionary(pair => pair.index, pair => pair.outcome);
 1936
 11937        var mappings = new List<CompletedRankingEventMapping>();
 11938        foreach (var header in rankingTable.QuerySelectorAll("thead th.ereignis[data-spiel='true']"))
 1939        {
 11940            if (!int.TryParse(header.GetAttribute("data-index"), out var eventIndex))
 1941            {
 1942                continue;
 1943            }
 1944
 11945            var headerTippSpielId = ExtractTippSpielId(header.QuerySelector("a")?.GetAttribute("href"));
 11946            CollectedMatchOutcome? mappedOutcome = null;
 1947
 11948            if (!string.IsNullOrWhiteSpace(headerTippSpielId)
 11949                && outcomesByTippSpielId.TryGetValue(headerTippSpielId, out var byTippSpielId))
 1950            {
 11951                mappedOutcome = byTippSpielId;
 1952            }
 01953            else if (outcomesByEventIndex.TryGetValue(eventIndex, out var byEventIndex))
 1954            {
 01955                mappedOutcome = byEventIndex;
 1956            }
 1957
 11958            if (mappedOutcome is null || !mappedOutcome.HasOutcome)
 1959            {
 1960                continue;
 1961            }
 1962
 11963            var sourceMatchId = mappedOutcome.TippSpielId
 11964                ?? string.Join("|", mappedOutcome.Matchday, mappedOutcome.HomeTeam, mappedOutcome.AwayTeam);
 11965            mappings.Add(new CompletedRankingEventMapping(eventIndex, sourceMatchId, mappedOutcome.TippSpielId));
 1966        }
 1967
 11968        return mappings;
 1969    }
 1970
 1971    private static KicktippCommunityMatchPrediction ParseParticipantPredictionCell(
 1972        IElement predictionCell,
 1973        CompletedRankingEventMapping mapping)
 1974    {
 11975        var awardedPoints = ParseIntegerCell(predictionCell.QuerySelector("sub.p"));
 11976        var rawText = ExtractPredictionCellScoreText(predictionCell);
 11977        if (TryParseBetPrediction(rawText, out var prediction))
 1978        {
 11979            return new KicktippCommunityMatchPrediction(
 11980                mapping.EventIndex,
 11981                mapping.SourceMatchId,
 11982                mapping.TippSpielId,
 11983                KicktippCommunityPredictionStatus.Placed,
 11984                prediction,
 11985                awardedPoints);
 1986        }
 1987
 11988        return new KicktippCommunityMatchPrediction(
 11989            mapping.EventIndex,
 11990            mapping.SourceMatchId,
 11991            mapping.TippSpielId,
 11992            KicktippCommunityPredictionStatus.Missed,
 11993            null,
 11994            0);
 1995    }
 1996
 1997    private static string ExtractPredictionCellScoreText(IElement predictionCell)
 1998    {
 11999        return string.Concat(predictionCell.ChildNodes.Select(ExtractNodeText)).Trim();
 2000
 2001        static string ExtractNodeText(INode node)
 2002        {
 12003            if (node is IElement element && element.Matches("sub.p"))
 2004            {
 12005                return string.Empty;
 2006            }
 2007
 12008            return node.ChildNodes.Length == 0
 12009                ? node.TextContent ?? string.Empty
 12010                : string.Concat(node.ChildNodes.Select(ExtractNodeText));
 2011        }
 2012    }
 2013
 2014    private static bool TryParseBetPrediction(string? value, out BetPrediction? prediction)
 2015    {
 12016        prediction = null;
 12017        if (string.IsNullOrWhiteSpace(value))
 2018        {
 12019            return false;
 2020        }
 2021
 12022        var sanitized = Regex.Replace(value, @"\s+", string.Empty);
 12023        var match = Regex.Match(sanitized, @"^(\d+):(\d+)$");
 12024        if (!match.Success)
 2025        {
 02026            return false;
 2027        }
 2028
 12029        if (!int.TryParse(match.Groups[1].Value, out var homeGoals)
 12030            || !int.TryParse(match.Groups[2].Value, out var awayGoals))
 2031        {
 02032            return false;
 2033        }
 2034
 12035        prediction = new BetPrediction(homeGoals, awayGoals);
 12036        return true;
 2037    }
 2038
 2039    private static int ParseIntegerCell(IElement? element)
 2040    {
 12041        if (element == null)
 2042        {
 12043            return 0;
 2044        }
 2045
 12046        var raw = element.TextContent?.Trim() ?? string.Empty;
 12047        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        {
 12060            var url = $"{community}/tippabgabe";
 12061            var response = await _httpClient.GetAsync(url);
 2062
 12063            if (!response.IsSuccessStatusCode)
 2064            {
 12065                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 12066                return new Dictionary<Match, BetPrediction?>();
 2067            }
 2068
 12069            var content = await response.Content.ReadAsStringAsync();
 12070            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 2071
 12072            var placedPredictions = new Dictionary<Match, BetPrediction?>();
 2073
 2074            // Extract matchday from the page
 12075            var currentMatchday = ExtractMatchdayFromPage(document);
 12076            _logger.LogDebug("Extracted matchday for placed predictions: {Matchday}", currentMatchday);
 2077
 2078            // Parse matches from the tippabgabe table
 12079            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 12080            if (matchTable == null)
 2081            {
 12082                _logger.LogWarning("Could not find tippabgabe table");
 12083                return placedPredictions;
 2084            }
 2085
 12086            var matchRows = matchTable.QuerySelectorAll("tr");
 12087            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 2088
 12089            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 2090
 12091            foreach (var row in matchRows)
 2092            {
 2093                try
 2094                {
 12095                    var cells = row.QuerySelectorAll("td");
 12096                    if (cells.Length >= 4)
 2097                    {
 2098                        // Extract match details from table cells
 12099                        var timeText = cells[0].TextContent?.Trim() ?? "";
 12100                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 12101                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 2102
 12103                        _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.
 12108                        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)
 12112                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 2113                        {
 12114                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 2115                            {
 12116                                if (isCancelled)
 2117                                {
 12118                                    _logger.LogWarning(
 12119                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 12120                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 12121                                        homeTeam, awayTeam, lastValidTimeText);
 2122                                }
 2123                                else
 2124                                {
 12125                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 2126                                }
 12127                                timeText = lastValidTimeText;
 2128                            }
 2129                            else
 2130                            {
 12131                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 12132                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 2133                            }
 2134                        }
 2135                        else
 2136                        {
 2137                            // Update the last valid time for future inheritance
 12138                            lastValidTimeText = timeText;
 12139                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 2140                        }
 2141
 2142                        // Look for betting inputs to get placed predictions
 12143                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 12144                        if (bettingInputs.Length >= 2)
 2145                        {
 12146                            var homeInput = bettingInputs[0] as IHtmlInputElement;
 12147                            var awayInput = bettingInputs[1] as IHtmlInputElement;
 2148
 2149                            // Parse the date/time
 12150                            var startsAt = ParseMatchDateTime(timeText);
 12151                            var match = new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled);
 2152
 2153                            // Check if predictions are placed (inputs have values)
 12154                            var homeValue = homeInput?.Value?.Trim();
 12155                            var awayValue = awayInput?.Value?.Trim();
 2156
 12157                            BetPrediction? prediction = null;
 12158                            if (!string.IsNullOrEmpty(homeValue) && !string.IsNullOrEmpty(awayValue))
 2159                            {
 12160                                if (int.TryParse(homeValue, out var homeGoals) && int.TryParse(awayValue, out var awayGo
 2161                                {
 12162                                    prediction = new BetPrediction(homeGoals, awayGoals);
 12163                                    _logger.LogDebug("Found placed prediction: {HomeTeam} vs {AwayTeam} = {Prediction}",
 2164                                }
 2165                                else
 2166                                {
 12167                                    _logger.LogWarning("Could not parse prediction values for {HomeTeam} vs {AwayTeam}: 
 2168                                }
 2169                            }
 2170                            else
 2171                            {
 12172                                _logger.LogDebug("No prediction placed for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 2173                            }
 2174
 12175                            placedPredictions[match] = prediction;
 2176                        }
 2177                    }
 12178                }
 02179                catch (Exception ex)
 2180                {
 02181                    _logger.LogWarning(ex, "Error parsing match row");
 02182                    continue;
 2183                }
 2184            }
 2185
 12186            _logger.LogInformation("Successfully parsed {MatchCount} matches with {PlacedCount} placed predictions",
 12187                placedPredictions.Count, placedPredictions.Values.Count(p => p != null));
 12188            return placedPredictions;
 2189        }
 02190        catch (Exception ex)
 2191        {
 02192            _logger.LogError(ex, "Exception in GetPlacedPredictionsAsync");
 02193            return new Dictionary<Match, BetPrediction?>();
 2194        }
 12195    }
 2196
 2197    private int ExtractMatchdayFromPage(IDocument document)
 2198    {
 2199        try
 2200        {
 2201            // Hidden fields are the most stable source across league and tournament pages.
 12202            foreach (var input in document.QuerySelectorAll("input"))
 2203            {
 12204                var name = input.GetAttribute("name") ?? string.Empty;
 12205                var value = input.GetAttribute("value") ?? string.Empty;
 12206                if (name.Contains("spieltag", StringComparison.OrdinalIgnoreCase) &&
 12207                    TryParsePositiveInteger(value, out var matchdayFromHiddenInput))
 2208                {
 12209                    _logger.LogDebug("Extracted matchday from hidden input {InputName}: {Matchday}", name, matchdayFromH
 12210                    return matchdayFromHiddenInput;
 2211                }
 2212            }
 2213
 12214            foreach (var select in document.QuerySelectorAll("select"))
 2215            {
 02216                var name = select.GetAttribute("name") ?? string.Empty;
 02217                if (!name.Contains("spieltag", StringComparison.OrdinalIgnoreCase))
 2218                {
 2219                    continue;
 2220                }
 2221
 02222                var selectedRoundOption = select.QuerySelector("option[selected]");
 02223                var selectedRoundValue = selectedRoundOption?.GetAttribute("value");
 02224                if (TryParsePositiveInteger(selectedRoundValue, out var matchdayFromSelectedOption))
 2225                {
 02226                    _logger.LogDebug("Extracted matchday from selected round option: {Matchday}", matchdayFromSelectedOp
 02227                    return matchdayFromSelectedOption;
 2228                }
 2229            }
 2230
 2231            // Fallback: extract any numeric round marker from common navigation elements.
 12232            foreach (var element in document.QuerySelectorAll(".prevnextTitle a, .prevnextTitle, .pagination .active, .p
 2233            {
 12234                var text = NormalizeWhitespace(element.TextContent);
 12235                if (TryExtractFirstPositiveInteger(text, out var matchdayFromNavigation))
 2236                {
 12237                    _logger.LogDebug("Extracted matchday from navigation text '{NavigationText}': {Matchday}", text, mat
 12238                    return matchdayFromNavigation;
 2239                }
 2240            }
 2241
 12242            _logger.LogWarning("Could not extract matchday from page, defaulting to 1");
 12243            return 1;
 2244        }
 02245        catch (Exception ex)
 2246        {
 02247            _logger.LogError(ex, "Error extracting matchday from page, defaulting to 1");
 02248            return 1;
 2249        }
 12250    }
 2251
 2252    private static bool TryExtractFirstPositiveInteger(string? text, out int value)
 2253    {
 12254        value = 0;
 12255        if (string.IsNullOrWhiteSpace(text))
 2256        {
 02257            return false;
 2258        }
 2259
 12260        var match = Regex.Match(text, @"\b(\d+)\b");
 12261        return match.Success && TryParsePositiveInteger(match.Groups[1].Value, out value);
 2262    }
 2263
 2264    private static bool TryParsePositiveInteger(string? text, out int value)
 2265    {
 12266        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        {
 12274            var url = $"{community}/tippabgabe?bonus=true";
 12275            var response = await _httpClient.GetAsync(url);
 2276
 12277            if (!response.IsSuccessStatusCode)
 2278            {
 12279                _logger.LogError("Failed to fetch tippabgabe page for bonus questions. Status: {StatusCode}", response.S
 12280                return new List<BonusQuestion>();
 2281            }
 2282
 12283            var content = await response.Content.ReadAsStringAsync();
 12284            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 2285
 12286            var bonusQuestions = new List<BonusQuestion>();
 2287
 2288            // Parse bonus questions from the tippabgabeFragen table
 12289            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 12290            if (bonusTable == null)
 2291            {
 12292                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 12293                return bonusQuestions;
 2294            }
 2295
 12296            var questionRows = bonusTable.QuerySelectorAll("tr");
 12297            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows", questionRows.Length);
 2298
 12299            foreach (var row in questionRows)
 2300            {
 12301                var cells = row.QuerySelectorAll("td");
 12302                if (cells.Length < 3) continue;
 2303
 2304                // Extract deadline and question text
 12305                var deadlineText = cells[0]?.TextContent?.Trim();
 12306                var questionText = cells[1]?.TextContent?.Trim();
 2307
 12308                if (string.IsNullOrEmpty(questionText)) continue;
 2309
 2310                // Parse deadline
 12311                var deadline = ParseMatchDateTime(deadlineText ?? "");
 2312
 2313                // Extract options from select elements
 12314                var tipCell = cells[2];
 12315                var selectElements = tipCell?.QuerySelectorAll("select");
 12316                var options = new List<BonusQuestionOption>();
 12317                string? formFieldName = null;
 12318                int maxSelections = 1; // Default to single selection
 2319
 12320                if (selectElements != null && selectElements.Length > 0)
 2321                {
 2322                    // The number of select elements indicates how many selections are allowed
 12323                    maxSelections = selectElements.Length;
 2324
 2325                    // Use the first select element to get the available options
 12326                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 12327                    formFieldName = firstSelect?.Name;
 2328
 12329                    var optionElements = firstSelect?.QuerySelectorAll("option");
 12330                    if (optionElements != null)
 2331                    {
 12332                        foreach (var option in optionElements.Cast<IHtmlOptionElement>())
 2333                        {
 12334                            if (option.Value != "-1" && !string.IsNullOrEmpty(option.Text))
 2335                            {
 12336                                options.Add(new BonusQuestionOption(option.Value, option.Text.Trim()));
 2337                            }
 2338                        }
 2339                    }
 2340                }
 2341
 12342                if (options.Any())
 2343                {
 12344                    bonusQuestions.Add(new BonusQuestion(
 12345                        Text: questionText,
 12346                        Deadline: deadline,
 12347                        Options: options,
 12348                        MaxSelections: maxSelections,
 12349                        FormFieldName: formFieldName
 12350                    ));
 2351                }
 2352            }
 2353
 12354            _logger.LogInformation("Successfully parsed {QuestionCount} bonus questions", bonusQuestions.Count);
 12355            return bonusQuestions;
 2356        }
 02357        catch (Exception ex)
 2358        {
 02359            _logger.LogError(ex, "Exception in GetOpenBonusQuestionsAsync");
 02360            return new List<BonusQuestion>();
 2361        }
 12362    }
 2363
 2364    /// <inheritdoc />
 2365    public async Task<Dictionary<string, BonusPrediction?>> GetPlacedBonusPredictionsAsync(string community)
 2366    {
 2367        try
 2368        {
 12369            var url = $"{community}/tippabgabe?bonus=true";
 12370            var response = await _httpClient.GetAsync(url);
 2371
 12372            if (!response.IsSuccessStatusCode)
 2373            {
 12374                _logger.LogError("Failed to fetch tippabgabe page for placed bonus predictions. Status: {StatusCode}", r
 12375                return new Dictionary<string, BonusPrediction?>();
 2376            }
 2377
 12378            var content = await response.Content.ReadAsStringAsync();
 12379            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 2380
 12381            var placedPredictions = new Dictionary<string, BonusPrediction?>();
 2382
 2383            // Parse bonus questions from the tippabgabeFragen table
 12384            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 12385            if (bonusTable == null)
 2386            {
 12387                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 12388                return placedPredictions;
 2389            }
 2390
 12391            var questionRows = bonusTable.QuerySelectorAll("tr");
 12392            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows for placed predictions", questionRo
 2393
 12394            foreach (var row in questionRows)
 2395            {
 12396                var cells = row.QuerySelectorAll("td");
 12397                if (cells.Length < 3) continue;
 2398
 2399                // Extract question text
 12400                var questionText = cells[1]?.TextContent?.Trim();
 12401                if (string.IsNullOrEmpty(questionText)) continue;
 2402
 2403                // Extract current selections from select elements
 12404                var tipCell = cells[2];
 12405                var selectElements = tipCell?.QuerySelectorAll("select");
 2406
 12407                if (selectElements != null && selectElements.Length > 0)
 2408                {
 2409                    // Extract form field name from the first select element
 12410                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 12411                    var formFieldName = firstSelect?.Name;
 2412
 12413                    var selectedOptionIds = new List<string>();
 2414
 2415                    // Check each select element for its current selection
 12416                    foreach (var selectElement in selectElements.Cast<IHtmlSelectElement>())
 2417                    {
 12418                        var selectedOption = selectElement.SelectedOptions.FirstOrDefault();
 12419                        if (selectedOption != null && selectedOption.Value != "-1" && !string.IsNullOrEmpty(selectedOpti
 2420                        {
 12421                            selectedOptionIds.Add(selectedOption.Value);
 2422                        }
 2423                    }
 2424
 2425                    // Use form field name as key, fall back to question text
 12426                    var dictionaryKey = formFieldName ?? questionText;
 2427
 2428                    // Only create a prediction if there are actual selections
 12429                    if (selectedOptionIds.Any())
 2430                    {
 12431                        placedPredictions[dictionaryKey] = new BonusPrediction(selectedOptionIds);
 2432                    }
 2433                    else
 2434                    {
 12435                        placedPredictions[dictionaryKey] = null; // No prediction placed
 2436                    }
 2437                }
 2438            }
 2439
 12440            _logger.LogInformation("Successfully retrieved placed predictions for {QuestionCount} bonus questions", plac
 12441            return placedPredictions;
 2442        }
 02443        catch (Exception ex)
 2444        {
 02445            _logger.LogError(ex, "Exception in GetPlacedBonusPredictionsAsync");
 02446            return new Dictionary<string, BonusPrediction?>();
 2447        }
 12448    }
 2449
 2450    /// <inheritdoc />
 2451    public async Task<bool> PlaceBonusPredictionsAsync(string community, Dictionary<string, BonusPrediction> predictions
 2452    {
 2453        try
 2454        {
 12455            if (!predictions.Any())
 2456            {
 12457                _logger.LogInformation("No bonus predictions to place");
 12458                return true;
 2459            }
 2460
 12461            var url = $"{community}/tippabgabe?bonus=true";
 12462            var response = await _httpClient.GetAsync(url);
 2463
 12464            if (!response.IsSuccessStatusCode)
 2465            {
 12466                _logger.LogError("Failed to access betting page for bonus predictions. Status: {StatusCode}", response.S
 12467                return false;
 2468            }
 2469
 12470            var pageContent = await response.Content.ReadAsStringAsync();
 12471            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 2472
 2473            // Find the bet form
 12474            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 12475            if (betForm == null)
 2476            {
 12477                _logger.LogWarning("Could not find betting form on the page");
 12478                return false;
 2479            }
 2480
 12481            var formData = new List<KeyValuePair<string, string>>();
 2482
 2483            // Copy hidden inputs from the original form
 12484            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 12485            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 2486            {
 12487                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 2488                {
 12489                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 2490                }
 2491            }
 2492
 2493            // Copy existing match predictions to avoid overwriting them
 12494            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 12495            foreach (var input in allInputs)
 2496            {
 12497                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 2498                {
 12499                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 2500                }
 2501            }
 2502
 2503            // Add bonus predictions
 12504            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 12505            if (bonusTable != null)
 2506            {
 12507                var questionRows = bonusTable.QuerySelectorAll("tr");
 2508
 12509                foreach (var row in questionRows)
 2510                {
 12511                    var cells = row.QuerySelectorAll("td");
 12512                    if (cells.Length < 3) continue;
 2513
 12514                    var tipCell = cells[2];
 12515                    var selectElements = tipCell?.QuerySelectorAll("select");
 2516
 12517                    if (selectElements != null)
 2518                    {
 12519                        var selectArray = selectElements.Cast<IHtmlSelectElement>().ToArray();
 2520
 2521                        // Check if we have a prediction for this question based on form field name match
 12522                        var matchingPrediction = predictions.FirstOrDefault(p =>
 12523                            selectArray.Any(sel => sel.Name == p.Key) ||
 12524                            selectArray.Any(sel => sel.Name?.Contains(p.Key) == true));
 2525
 12526                        if (matchingPrediction.Value != null && matchingPrediction.Value.SelectedOptionIds.Any())
 2527                        {
 12528                            var selectedOptions = matchingPrediction.Value.SelectedOptionIds;
 2529
 2530                            // For multi-selection questions, we need to fill multiple select elements
 12531                            for (int i = 0; i < Math.Min(selectArray.Length, selectedOptions.Count); i++)
 2532                            {
 12533                                var selectElement = selectArray[i];
 12534                                var fieldName = selectElement.Name;
 12535                                if (string.IsNullOrEmpty(fieldName)) continue;
 2536
 12537                                var selectedOptionId = selectedOptions[i];
 2538
 2539                                // Check if this option exists in the select element
 12540                                var optionExists = selectElement.QuerySelectorAll("option")
 12541                                    .Cast<IHtmlOptionElement>()
 12542                                    .Any(opt => opt.Value == selectedOptionId);
 2543
 12544                                if (optionExists)
 2545                                {
 12546                                    formData.Add(new KeyValuePair<string, string>(fieldName, selectedOptionId));
 12547                                    _logger.LogDebug("Added bonus prediction for field {FieldName}: {OptionId} (selectio
 12548                                        fieldName, selectedOptionId, i + 1);
 2549                                }
 2550                                else
 2551                                {
 12552                                    _logger.LogWarning("Option {OptionId} not found for field {FieldName}", selectedOpti
 2553                                }
 2554                            }
 2555                        }
 2556                    }
 2557                }
 2558            }
 2559
 2560            // Find submit button
 12561            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 12562            if (submitButton != null)
 2563            {
 12564                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 2565                {
 12566                    formData.Add(new KeyValuePair<string, string>(inputSubmit.Name, inputSubmit.Value ?? "Submit"));
 2567                }
 12568                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 2569                {
 12570                    formData.Add(new KeyValuePair<string, string>(buttonSubmit.Name, buttonSubmit.Value ?? "Submit"));
 2571                }
 2572            }
 2573            else
 2574            {
 2575                // Fallback to default submit button name
 12576                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 2577            }
 2578
 2579            // Submit form
 12580            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 12581                (betForm.Action.StartsWith("http") ? betForm.Action :
 12582                 betForm.Action.StartsWith("/") ? betForm.Action :
 12583                 $"{community}/{betForm.Action}");
 2584
 12585            var formContent = new FormUrlEncodedContent(formData);
 12586            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 2587
 12588            if (submitResponse.IsSuccessStatusCode)
 2589            {
 12590                _logger.LogInformation("✓ Successfully submitted {PredictionCount} bonus predictions!", predictions.Coun
 12591                return true;
 2592            }
 2593            else
 2594            {
 12595                _logger.LogError("✗ Failed to submit bonus predictions. Status: {StatusCode}", submitResponse.StatusCode
 12596                return false;
 2597            }
 2598        }
 02599        catch (Exception ex)
 2600        {
 02601            _logger.LogError(ex, "Exception during bonus prediction placement");
 02602            return false;
 2603        }
 12604    }
 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    {
 12613        if (string.IsNullOrWhiteSpace(annotation))
 12614            return null;
 2615
 12616        return annotation.Trim() switch
 12617        {
 12618            "n.E." => "nach Elfmeterschießen",
 12619            "n.V." => "nach Verlängerung",
 12620            _ => annotation.Trim() // Return as-is if not recognized
 12621        };
 2622    }
 2623
 2624    public void Dispose()
 2625    {
 02626        _httpClient?.Dispose();
 02627        _browsingContext?.Dispose();
 02628    }
 2629}

Methods/Properties

.cctor()
.ctor(System.Net.Http.HttpClient, Microsoft.Extensions.Logging.ILogger<KicktippIntegration.KicktippClient>, Microsoft.Extensions.Caching.Memory.IMemoryCache)
GetOpenPredictionsAsync()
PlaceBetAsync()
PlaceBetsAsync()
GetStandingsAsync()
GetMatchesWithHistoryAsync(string)
GetMatchesWithHistoryAsync(string, int)
GetMatchesWithHistoryAsync()
GetCurrentTippuebersichtMatchdayAsync()
GetMatchdayOutcomesAsync()
GetCommunityMatchdaySnapshotAsync()
GetHomeAwayHistoryAsync()
GetHeadToHeadHistoryAsync()
GetHeadToHeadDetailedHistoryAsync()
IsMatchOnPage(AngleSharp.Dom.IDocument, string, string)
ExtractMatchWithHistoryFromSpielinfoPage(AngleSharp.Dom.IDocument, int)
ExtractTeamHistory(AngleSharp.Dom.IDocument, string)
ExtractHeadToHeadHistory(AngleSharp.Dom.IDocument)
FindNextMatchLink(AngleSharp.Dom.IDocument)
ParseMatchDateTime(string)
ExtractStandingsGroupName(AngleSharp.Dom.IElement)
ExtractGroupLabelFromPreviousSiblings(AngleSharp.Dom.IElement)
ContainsOnlyCurrentStandingsTable(AngleSharp.Dom.IElement, AngleSharp.Dom.IElement)
IsStandingsTableContainer(AngleSharp.Dom.IElement)
IsHeading(AngleSharp.Dom.IElement)
ExtractGroupLabel(string)
NormalizeWhitespace(string)
IsCancelledTimeText(string)
GetTippuebersichtDocumentAsync()
ParseTippuebersichtMatchdayOutcomes(AngleSharp.Dom.IDocument, int)
ParseTippuebersichtParticipantSnapshots(AngleSharp.Dom.IDocument, int, System.Collections.Generic.IReadOnlyList<EHonda.KicktippAi.Core.CollectedMatchOutcome>)
ParseMatchOutcome(AngleSharp.Dom.IElement)
ExtractTippSpielId(string)
BuildCompletedRankingEventMappings(AngleSharp.Dom.IElement, System.Collections.Generic.IReadOnlyList<EHonda.KicktippAi.Core.CollectedMatchOutcome>)
ParseParticipantPredictionCell(AngleSharp.Dom.IElement, KicktippIntegration.KicktippClient.CompletedRankingEventMapping)
ExtractPredictionCellScoreText(AngleSharp.Dom.IElement)
ExtractNodeText()
TryParseBetPrediction(string, out KicktippIntegration.BetPrediction)
ParseIntegerCell(AngleSharp.Dom.IElement)
.ctor(int, string, string)
GetPlacedPredictionsAsync()
ExtractMatchdayFromPage(AngleSharp.Dom.IDocument)
TryExtractFirstPositiveInteger(string, out int)
TryParsePositiveInteger(string, out int)
GetOpenBonusQuestionsAsync()
GetPlacedBonusPredictionsAsync()
PlaceBonusPredictionsAsync()
ExpandAnnotation(string)
Dispose()