< Summary

Information
Class: KicktippIntegration.KicktippClient
Assembly: KicktippIntegration
File(s): /home/runner/work/KicktippAi/KicktippAi/src/KicktippIntegration/KicktippClient.cs
Line coverage
79%
Covered lines: 833
Uncovered lines: 221
Coverable lines: 1054
Total lines: 2210
Line coverage: 79%
Branch coverage
73%
Covered branches: 594
Total branches: 806
Branch coverage: 73.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Net;
 2using Regex = System.Text.RegularExpressions.Regex;
 3using AngleSharp;
 4using AngleSharp.Dom;
 5using AngleSharp.Html.Dom;
 6using EHonda.KicktippAi.Core;
 7using Microsoft.Extensions.Caching.Memory;
 8using Microsoft.Extensions.Logging;
 9using NodaTime;
 10using NodaTime.Extensions;
 11
 12namespace KicktippIntegration;
 13
 14/// <summary>
 15/// Implementation of IKicktippClient for interacting with kicktipp.de website
 16/// Authentication is handled automatically via KicktippAuthenticationHandler
 17/// </summary>
 18public class KicktippClient : IKicktippClient, IDisposable
 19{
 20    private readonly HttpClient _httpClient;
 21    private readonly ILogger<KicktippClient> _logger;
 22    private readonly IBrowsingContext _browsingContext;
 23    private readonly IMemoryCache _cache;
 24
 125    public KicktippClient(HttpClient httpClient, ILogger<KicktippClient> logger, IMemoryCache cache)
 26    {
 127        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
 128        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 129        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
 30
 131        var config = Configuration.Default.WithDefaultLoader();
 132        _browsingContext = BrowsingContext.New(config);
 133    }
 34
 35    /// <inheritdoc />
 36    public async Task<List<Match>> GetOpenPredictionsAsync(string community)
 37    {
 38        try
 39        {
 140            var url = $"{community}/tippabgabe";
 141            var response = await _httpClient.GetAsync(url);
 42
 143            if (!response.IsSuccessStatusCode)
 44            {
 145                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 146                return new List<Match>();
 47            }
 48
 149            var content = await response.Content.ReadAsStringAsync();
 150            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 51
 152            var matches = new List<Match>();
 53
 54            // Extract matchday from the page
 155            var currentMatchday = ExtractMatchdayFromPage(document);
 156            _logger.LogDebug("Extracted matchday: {Matchday}", currentMatchday);
 57
 58            // Parse matches from the tippabgabe table
 159            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 160            if (matchTable == null)
 61            {
 162                _logger.LogWarning("Could not find tippabgabe table");
 163                return matches;
 64            }
 65
 166            var matchRows = matchTable.QuerySelectorAll("tr");
 167            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 68
 169            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 70
 171            foreach (var row in matchRows)
 72            {
 73                try
 74                {
 175                    var cells = row.QuerySelectorAll("td");
 176                    if (cells.Length >= 4)
 77                    {
 78                        // Extract match details from table cells
 179                        var timeText = cells[0].TextContent?.Trim() ?? "";
 180                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 181                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 82
 83                        // Check if match is cancelled ("Abgesagt" in German)
 84                        // Cancelled matches still accept predictions on Kicktipp, so we process them.
 85                        // See docs/features/cancelled-matches.md for design rationale.
 186                        var isCancelled = IsCancelledTimeText(timeText);
 87
 88                        // Handle date inheritance: if timeText is empty or cancelled, use the last valid time
 89                        // This preserves database key consistency (startsAt is part of the composite key)
 190                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 91                        {
 192                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 93                            {
 194                                if (isCancelled)
 95                                {
 196                                    _logger.LogWarning(
 197                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 198                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 199                                        homeTeam, awayTeam, lastValidTimeText);
 100                                }
 101                                else
 102                                {
 1103                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 104                                }
 1105                                timeText = lastValidTimeText;
 106                            }
 107                            else
 108                            {
 0109                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 0110                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 111                            }
 112                        }
 113                        else
 114                        {
 115                            // Update the last valid time for future inheritance
 1116                            lastValidTimeText = timeText;
 1117                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 118                        }
 119
 120                        // Check if this row has betting inputs (indicates open match)
 1121                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 1122                        if (bettingInputs.Length >= 2)
 123                        {
 1124                            _logger.LogDebug("Found open match: {HomeTeam} vs {AwayTeam} at {Time}{Cancelled}",
 1125                                homeTeam, awayTeam, timeText, isCancelled ? " (CANCELLED)" : "");
 126
 127                            // Parse the date/time - for now use a simple approach
 128                            // Format appears to be "08.07.25 21:00"
 1129                            var startsAt = ParseMatchDateTime(timeText);
 130
 1131                            matches.Add(new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled));
 132                        }
 133                    }
 1134                }
 0135                catch (Exception ex)
 136                {
 0137                    _logger.LogWarning(ex, "Error parsing match row");
 0138                    continue;
 139                }
 140            }
 141
 1142            _logger.LogInformation("Successfully parsed {MatchCount} open matches", matches.Count);
 1143            return matches;
 144        }
 0145        catch (Exception ex)
 146        {
 0147            _logger.LogError(ex, "Exception in GetOpenPredictionsAsync");
 0148            return new List<Match>();
 149        }
 1150    }
 151
 152    /// <inheritdoc />
 153    public async Task<bool> PlaceBetAsync(string community, Match match, BetPrediction prediction, bool overrideBet = fa
 154    {
 155        try
 156        {
 1157            var url = $"{community}/tippabgabe";
 1158            var response = await _httpClient.GetAsync(url);
 159
 1160            if (!response.IsSuccessStatusCode)
 161            {
 1162                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 1163                return false;
 164            }
 165
 1166            var pageContent = await response.Content.ReadAsStringAsync();
 1167            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 168
 169            // Find the bet form
 1170            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 1171            if (betForm == null)
 172            {
 1173                _logger.LogWarning("Could not find betting form on the page");
 1174                return false;
 175            }
 176
 177            // Find the main content area
 1178            var contentArea = document.QuerySelector("#kicktipp-content");
 1179            if (contentArea == null)
 180            {
 1181                _logger.LogWarning("Could not find content area on the betting page");
 1182                return false;
 183            }
 184
 185            // Find the table with predictions
 1186            var tbody = contentArea.QuerySelector("tbody");
 1187            if (tbody == null)
 188            {
 1189                _logger.LogWarning("No betting table found");
 1190                return false;
 191            }
 192
 1193            var rows = tbody.QuerySelectorAll("tr");
 1194            var formData = new List<KeyValuePair<string, string>>();
 1195            var matchFound = false;
 196
 197            // Copy hidden inputs from the original form
 1198            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 1199            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 200            {
 1201                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 202                {
 1203                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 204                }
 205            }
 206
 207            // Find the specific match in the form and set its bet
 1208            foreach (var row in rows)
 209            {
 1210                var cells = row.QuerySelectorAll("td");
 1211                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 212
 213                try
 214                {
 1215                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1216                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 217
 1218                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 0219                        continue;
 220
 221                    // Check if this is the match we want to bet on
 1222                    if (homeTeam == match.HomeTeam && roadTeam == match.AwayTeam)
 223                    {
 224                        // Find bet input fields in the row
 1225                        var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1226                        var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 227
 1228                        if (homeInput == null || awayInput == null)
 229                        {
 1230                            _logger.LogWarning("No betting inputs found for {Match}, skipping", match);
 1231                            continue;
 232                        }
 233
 234                        // Check if bets are already placed
 1235                        var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 1236                        var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 237
 1238                        if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBet)
 239                        {
 1240                            var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 1241                            _logger.LogInformation("{Match} - skipped, already placed {ExistingBet}", match, existingBet
 1242                            return true; // Consider this successful - bet already exists
 243                        }
 244
 245                        // Add bet to form data
 1246                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 247                        {
 1248                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString(
 1249                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString(
 1250                            matchFound = true;
 1251                            _logger.LogInformation("{Match} - betting {Prediction}", match, prediction);
 252                        }
 253                        else
 254                        {
 0255                            _logger.LogWarning("{Match} - input field names are missing, skipping", match);
 0256                            continue;
 257                        }
 258
 1259                        break; // Found our match, no need to continue
 260                    }
 1261                }
 0262                catch (Exception ex)
 263                {
 0264                    _logger.LogError(ex, "Error processing betting row");
 0265                    continue;
 266                }
 267            }
 268
 1269            if (!matchFound)
 270            {
 1271                _logger.LogWarning("Match {Match} not found in betting form", match);
 1272                return false;
 273            }
 274
 275            // Add other input fields that might have existing values
 1276            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 1277            foreach (var input in allInputs)
 278            {
 1279                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 280                {
 281                    // Only add if we haven't already added this field
 1282                    if (!formData.Any(kv => kv.Key == input.Name))
 283                    {
 1284                        formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 285                    }
 286                }
 287            }
 288
 289            // Find submit button
 1290            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 1291            var submitName = "submitbutton"; // Default from Python
 292
 1293            if (submitButton != null)
 294            {
 1295                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 296                {
 1297                    submitName = inputSubmit.Name;
 1298                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 299                }
 1300                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 301                {
 1302                    submitName = buttonSubmit.Name;
 1303                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 304                }
 305            }
 306            else
 307            {
 308                // Fallback to default submit button name
 1309                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 310            }
 311
 312            // Submit form
 1313            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 1314                (betForm.Action.StartsWith("http") ? betForm.Action :
 1315                 betForm.Action.StartsWith("/") ? betForm.Action :
 1316                 $"{community}/{betForm.Action}");
 317
 1318            var formContent = new FormUrlEncodedContent(formData);
 1319            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 320
 1321            if (submitResponse.IsSuccessStatusCode)
 322            {
 1323                _logger.LogInformation("✓ Successfully submitted bet for {Match}!", match);
 1324                return true;
 325            }
 326            else
 327            {
 1328                _logger.LogError("✗ Failed to submit bet. Status: {StatusCode}", submitResponse.StatusCode);
 1329                return false;
 330            }
 331        }
 0332        catch (Exception ex)
 333        {
 0334            _logger.LogError(ex, "Exception during bet placement");
 0335            return false;
 336        }
 1337    }
 338
 339    /// <inheritdoc />
 340    public async Task<bool> PlaceBetsAsync(string community, Dictionary<Match, BetPrediction> bets, bool overrideBets = 
 341    {
 342        try
 343        {
 1344            var url = $"{community}/tippabgabe";
 1345            var response = await _httpClient.GetAsync(url);
 346
 1347            if (!response.IsSuccessStatusCode)
 348            {
 1349                _logger.LogError("Failed to access betting page. Status: {StatusCode}", response.StatusCode);
 1350                return false;
 351            }
 352
 1353            var pageContent = await response.Content.ReadAsStringAsync();
 1354            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 355
 356            // Find the bet form
 1357            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 1358            if (betForm == null)
 359            {
 1360                _logger.LogWarning("Could not find betting form on the page");
 1361                return false;
 362            }
 363
 364            // Find the main content area
 1365            var contentArea = document.QuerySelector("#kicktipp-content");
 1366            if (contentArea == null)
 367            {
 1368                _logger.LogWarning("Could not find content area on the betting page");
 1369                return false;
 370            }
 371
 372            // Find the table with predictions
 1373            var tbody = contentArea.QuerySelector("tbody");
 1374            if (tbody == null)
 375            {
 1376                _logger.LogWarning("No betting table found");
 1377                return false;
 378            }
 379
 1380            var rows = tbody.QuerySelectorAll("tr");
 1381            var formData = new List<KeyValuePair<string, string>>();
 1382            var betsPlaced = 0;
 1383            var betsSkipped = 0;
 384
 385            // Add hidden fields from the form
 1386            var hiddenInputs = betForm.QuerySelectorAll("input[type=hidden]").OfType<IHtmlInputElement>();
 1387            foreach (var input in hiddenInputs)
 388            {
 1389                if (!string.IsNullOrEmpty(input.Name) && input.Value != null)
 390                {
 1391                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 392                }
 393            }
 394
 395            // Process all matches in the form
 1396            foreach (var row in rows)
 397            {
 1398                var cells = row.QuerySelectorAll("td");
 1399                if (cells.Length < 4) continue; // Need at least date, home team, road team, and bet inputs
 400
 401                try
 402                {
 1403                    var homeTeam = cells[1].TextContent?.Trim() ?? "";
 1404                    var roadTeam = cells[2].TextContent?.Trim() ?? "";
 405
 1406                    if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(roadTeam))
 1407                        continue;
 408
 409                    // Check if we have a bet for this match
 1410                    var matchKey = bets.Keys.FirstOrDefault(m => m.HomeTeam == homeTeam && m.AwayTeam == roadTeam);
 1411                    if (matchKey == null)
 412                    {
 413                        // Add existing bet values to maintain form state
 1414                        var existingHomeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1415                        var existingAwayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 416
 1417                        if (existingHomeInput != null && existingAwayInput != null &&
 1418                            !string.IsNullOrEmpty(existingHomeInput.Name) && !string.IsNullOrEmpty(existingAwayInput.Nam
 419                        {
 1420                            formData.Add(new KeyValuePair<string, string>(existingHomeInput.Name, existingHomeInput.Valu
 1421                            formData.Add(new KeyValuePair<string, string>(existingAwayInput.Name, existingAwayInput.Valu
 422                        }
 1423                        continue;
 424                    }
 425
 1426                    var prediction = bets[matchKey];
 427
 428                    // Find bet input fields in the row
 1429                    var homeInput = cells[3].QuerySelector("input[id$='_heimTipp']") as IHtmlInputElement;
 1430                    var awayInput = cells[3].QuerySelector("input[id$='_gastTipp']") as IHtmlInputElement;
 431
 1432                    if (homeInput == null || awayInput == null)
 433                    {
 1434                        _logger.LogWarning("No betting inputs found for {MatchKey}, skipping", matchKey);
 1435                        continue;
 436                    }
 437
 438                    // Check if bets are already placed
 1439                    var hasExistingHomeBet = !string.IsNullOrEmpty(homeInput.Value);
 1440                    var hasExistingAwayBet = !string.IsNullOrEmpty(awayInput.Value);
 441
 1442                    if ((hasExistingHomeBet || hasExistingAwayBet) && !overrideBets)
 443                    {
 1444                        var existingBet = $"{homeInput.Value ?? ""}:{awayInput.Value ?? ""}";
 1445                        _logger.LogInformation("{MatchKey} - skipped, already placed {ExistingBet}", matchKey, existingB
 1446                        betsSkipped++;
 447
 448                        // Keep existing values
 1449                        if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 450                        {
 1451                            formData.Add(new KeyValuePair<string, string>(homeInput.Name, homeInput.Value ?? ""));
 1452                            formData.Add(new KeyValuePair<string, string>(awayInput.Name, awayInput.Value ?? ""));
 453                        }
 1454                        continue;
 455                    }
 456
 457                    // Add bet to form data
 1458                    if (!string.IsNullOrEmpty(homeInput.Name) && !string.IsNullOrEmpty(awayInput.Name))
 459                    {
 1460                        formData.Add(new KeyValuePair<string, string>(homeInput.Name, prediction.HomeGoals.ToString()));
 1461                        formData.Add(new KeyValuePair<string, string>(awayInput.Name, prediction.AwayGoals.ToString()));
 1462                        betsPlaced++;
 1463                        _logger.LogInformation("{MatchKey} - betting {Prediction}", matchKey, prediction);
 464                    }
 465                    else
 466                    {
 0467                        _logger.LogWarning("{MatchKey} - input field names are missing, skipping", matchKey);
 468                        continue;
 469                    }
 1470                }
 0471                catch (Exception ex)
 472                {
 0473                    _logger.LogError(ex, "Error processing betting row");
 0474                    continue;
 475                }
 476            }
 477
 1478            _logger.LogInformation("Summary: {BetsPlaced} bets to place, {BetsSkipped} skipped", betsPlaced, betsSkipped
 479
 1480            if (betsPlaced == 0)
 481            {
 1482                _logger.LogInformation("No bets to place");
 1483                return true;
 484            }
 485
 486            // Find submit button
 1487            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 1488            var submitName = "submitbutton"; // Default from Python
 489
 1490            if (submitButton != null)
 491            {
 1492                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 493                {
 1494                    submitName = inputSubmit.Name;
 1495                    formData.Add(new KeyValuePair<string, string>(submitName, inputSubmit.Value ?? "Submit"));
 496                }
 1497                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 498                {
 1499                    submitName = buttonSubmit.Name;
 1500                    formData.Add(new KeyValuePair<string, string>(submitName, buttonSubmit.Value ?? "Submit"));
 501                }
 502            }
 503            else
 504            {
 505                // Fallback to default submit button name
 1506                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 507            }
 508
 509            // Submit form
 1510            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 1511                (betForm.Action.StartsWith("http") ? betForm.Action :
 1512                 betForm.Action.StartsWith("/") ? betForm.Action :
 1513                 $"{community}/{betForm.Action}");
 514
 1515            var formContent = new FormUrlEncodedContent(formData);
 1516            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 517
 1518            if (submitResponse.IsSuccessStatusCode)
 519            {
 1520                _logger.LogInformation("✓ Successfully submitted {BetsPlaced} bets!", betsPlaced);
 1521                return true;
 522            }
 523            else
 524            {
 1525                _logger.LogError("✗ Failed to submit bets. Status: {StatusCode}", submitResponse.StatusCode);
 1526                return false;
 527            }
 528        }
 0529        catch (Exception ex)
 530        {
 0531            _logger.LogError(ex, "Exception during bet placement");
 0532            return false;
 533        }
 1534    }
 535
 536    /// <inheritdoc />
 537    public async Task<List<TeamStanding>> GetStandingsAsync(string community)
 538    {
 539        // Create cache key based on community
 1540        var cacheKey = $"standings_{community}";
 541
 542        // Try to get from cache first
 1543        if (_cache.TryGetValue(cacheKey, out List<TeamStanding>? cachedStandings))
 544        {
 1545            _logger.LogDebug("Retrieved standings for {Community} from cache", community);
 1546            return cachedStandings!;
 547        }
 548
 549        try
 550        {
 1551            var url = $"{community}/tabellen";
 1552            var response = await _httpClient.GetAsync(url);
 553
 1554            if (!response.IsSuccessStatusCode)
 555            {
 1556                _logger.LogError("Failed to fetch standings page. Status: {StatusCode}", response.StatusCode);
 1557                return new List<TeamStanding>();
 558            }
 559
 1560            var content = await response.Content.ReadAsStringAsync();
 1561            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 562
 1563            var standings = new List<TeamStanding>();
 564
 565            // Find the standings table
 1566            var standingsTable = document.QuerySelector("table.sporttabelle tbody");
 1567            if (standingsTable == null)
 568            {
 1569                _logger.LogWarning("Could not find standings table");
 1570                return standings;
 571            }
 572
 1573            var rows = standingsTable.QuerySelectorAll("tr");
 1574            _logger.LogDebug("Found {RowCount} team rows in standings table", rows.Length);
 575
 1576            foreach (var row in rows)
 577            {
 578                try
 579                {
 1580                    var cells = row.QuerySelectorAll("td");
 1581                    if (cells.Length >= 9) // Need at least 9 columns for all data
 582                    {
 583                        // Extract data from table cells
 1584                        var positionText = cells[0].TextContent?.Trim().TrimEnd('.') ?? "";
 1585                        var teamNameElement = cells[1].QuerySelector("div");
 1586                        var teamName = teamNameElement?.TextContent?.Trim() ?? "";
 1587                        var gamesPlayedText = cells[2].TextContent?.Trim() ?? "";
 1588                        var pointsText = cells[3].TextContent?.Trim() ?? "";
 1589                        var goalsText = cells[4].TextContent?.Trim() ?? "";
 1590                        var goalDifferenceText = cells[5].TextContent?.Trim() ?? "";
 1591                        var winsText = cells[6].TextContent?.Trim() ?? "";
 1592                        var drawsText = cells[7].TextContent?.Trim() ?? "";
 1593                        var lossesText = cells[8].TextContent?.Trim() ?? "";
 594
 595                        // Parse numeric values
 1596                        if (int.TryParse(positionText, out var position) &&
 1597                            int.TryParse(gamesPlayedText, out var gamesPlayed) &&
 1598                            int.TryParse(pointsText, out var points) &&
 1599                            int.TryParse(goalDifferenceText, out var goalDifference) &&
 1600                            int.TryParse(winsText, out var wins) &&
 1601                            int.TryParse(drawsText, out var draws) &&
 1602                            int.TryParse(lossesText, out var losses))
 603                        {
 604                            // Parse goals (format: "15:8")
 1605                            var goalsParts = goalsText.Split(':');
 1606                            var goalsFor = 0;
 1607                            var goalsAgainst = 0;
 608
 1609                            if (goalsParts.Length == 2)
 610                            {
 1611                                int.TryParse(goalsParts[0], out goalsFor);
 1612                                int.TryParse(goalsParts[1], out goalsAgainst);
 613                            }
 614
 1615                            var teamStanding = new TeamStanding(
 1616                                position,
 1617                                teamName,
 1618                                gamesPlayed,
 1619                                points,
 1620                                goalsFor,
 1621                                goalsAgainst,
 1622                                goalDifference,
 1623                                wins,
 1624                                draws,
 1625                                losses);
 626
 1627                            standings.Add(teamStanding);
 1628                            _logger.LogDebug("Parsed team standing: {Position}. {TeamName} - {Points} points",
 1629                                position, teamName, points);
 630                        }
 631                        else
 632                        {
 1633                            _logger.LogWarning("Failed to parse numeric values for team row");
 634                        }
 635                    }
 1636                }
 0637                catch (Exception ex)
 638                {
 0639                    _logger.LogWarning(ex, "Error parsing standings row");
 0640                    continue;
 641                }
 642            }
 643
 1644            _logger.LogInformation("Successfully parsed {StandingsCount} team standings", standings.Count);
 645
 646            // Cache the results for 20 minutes (standings change relatively infrequently)
 1647            var cacheOptions = new MemoryCacheEntryOptions
 1648            {
 1649                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20),
 1650                SlidingExpiration = TimeSpan.FromMinutes(10) // Reset timer if accessed within 10 minutes
 1651            };
 1652            _cache.Set(cacheKey, standings, cacheOptions);
 1653            _logger.LogDebug("Cached standings for {Community} for 20 minutes", community);
 654
 1655            return standings;
 656        }
 0657        catch (Exception ex)
 658        {
 0659            _logger.LogError(ex, "Exception in GetStandingsAsync");
 0660            return new List<TeamStanding>();
 661        }
 1662    }
 663
 664    /// <inheritdoc />
 665    public async Task<List<MatchWithHistory>> GetMatchesWithHistoryAsync(string community)
 666    {
 667        // Create cache key based on community
 1668        var cacheKey = $"matches_history_{community}";
 669
 670        // Try to get from cache first
 1671        if (_cache.TryGetValue(cacheKey, out List<MatchWithHistory>? cachedMatches))
 672        {
 1673            _logger.LogDebug("Retrieved matches with history for {Community} from cache", community);
 1674            return cachedMatches!;
 675        }
 676
 677        try
 678        {
 1679            var matches = new List<MatchWithHistory>();
 680
 681            // First, get the tippabgabe page to find the link to spielinfos
 1682            var tippabgabeUrl = $"{community}/tippabgabe";
 1683            var response = await _httpClient.GetAsync(tippabgabeUrl);
 684
 1685            if (!response.IsSuccessStatusCode)
 686            {
 1687                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1688                return matches;
 689            }
 690
 1691            var content = await response.Content.ReadAsStringAsync();
 1692            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 693
 694            // Extract matchday from the tippabgabe page
 1695            var currentMatchday = ExtractMatchdayFromPage(document);
 1696            _logger.LogDebug("Extracted matchday for history extraction: {Matchday}", currentMatchday);
 697
 698            // Find the "Tippabgabe mit Spielinfos" link
 1699            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1700            if (spielinfoLink == null)
 701            {
 1702                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1703                return matches;
 704            }
 705
 1706            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1707            if (string.IsNullOrEmpty(spielinfoUrl))
 708            {
 0709                _logger.LogWarning("Spielinfo link has no href attribute");
 0710                return matches;
 711            }
 712
 713            // Make URL absolute if it's relative
 1714            if (spielinfoUrl.StartsWith("/"))
 715            {
 1716                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 717            }
 718
 1719            _logger.LogInformation("Starting to fetch match details from spielinfo pages...");
 720
 721            // Navigate through all matches using the right arrow navigation
 1722            var currentUrl = spielinfoUrl;
 1723            var matchCount = 0;
 724
 1725            while (!string.IsNullOrEmpty(currentUrl))
 726            {
 727                try
 728                {
 1729                    var spielinfoResponse = await _httpClient.GetAsync(currentUrl);
 1730                    if (!spielinfoResponse.IsSuccessStatusCode)
 731                    {
 1732                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", currentUrl, sp
 1733                        break;
 734                    }
 735
 1736                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1737                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 738
 739                    // Extract match information
 1740                    var matchWithHistory = ExtractMatchWithHistoryFromSpielinfoPage(spielinfoDocument, currentMatchday);
 1741                    if (matchWithHistory != null)
 742                    {
 1743                        matches.Add(matchWithHistory);
 1744                        matchCount++;
 1745                        _logger.LogDebug("Extracted match {Count}: {Match}", matchCount, matchWithHistory.Match);
 746                    }
 747
 748                    // Find the next match link (right arrow)
 1749                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1750                    if (nextLink != null)
 751                    {
 1752                        currentUrl = nextLink;
 1753                        if (currentUrl.StartsWith("/"))
 754                        {
 1755                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 756                        }
 757                    }
 758                    else
 759                    {
 760                        // No more matches
 1761                        break;
 762                    }
 1763                }
 0764                catch (Exception ex)
 765                {
 0766                    _logger.LogError(ex, "Error processing spielinfo page: {Url}", currentUrl);
 0767                    break;
 768                }
 769            }
 770
 1771            _logger.LogInformation("Successfully extracted {MatchCount} matches with history", matches.Count);
 772
 773            // Cache the results for 15 minutes (match info changes less frequently than live scores)
 1774            var cacheOptions = new MemoryCacheEntryOptions
 1775            {
 1776                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
 1777                SlidingExpiration = TimeSpan.FromMinutes(7) // Reset timer if accessed within 7 minutes
 1778            };
 1779            _cache.Set(cacheKey, matches, cacheOptions);
 1780            _logger.LogDebug("Cached matches with history for {Community} for 15 minutes", community);
 781
 1782            return matches;
 783        }
 0784        catch (Exception ex)
 785        {
 0786            _logger.LogError(ex, "Exception in GetMatchesWithHistoryAsync");
 0787            return new List<MatchWithHistory>();
 788        }
 1789    }
 790
 791    /// <inheritdoc />
 792    public async Task<int> GetCurrentTippuebersichtMatchdayAsync(string community)
 793    {
 0794        var document = await GetTippuebersichtDocumentAsync(community, null);
 0795        if (document == null)
 796        {
 0797            return 1;
 798        }
 799
 0800        return ExtractMatchdayFromPage(document);
 0801    }
 802
 803    /// <inheritdoc />
 804    public async Task<IReadOnlyList<CollectedMatchOutcome>> GetMatchdayOutcomesAsync(string community, int matchday)
 805    {
 0806        var cacheKey = $"tippuebersicht_outcomes_{community}_{matchday}";
 0807        if (_cache.TryGetValue(cacheKey, out IReadOnlyList<CollectedMatchOutcome>? cachedOutcomes))
 808        {
 0809            _logger.LogDebug("Retrieved tippuebersicht outcomes for {Community} matchday {Matchday} from cache", communi
 0810            return cachedOutcomes!;
 811        }
 812
 0813        var document = await GetTippuebersichtDocumentAsync(community, matchday);
 0814        if (document == null)
 815        {
 0816            return Array.Empty<CollectedMatchOutcome>();
 817        }
 818
 0819        var displayedMatchday = ExtractMatchdayFromPage(document);
 0820        if (displayedMatchday != matchday)
 821        {
 0822            _logger.LogWarning("Requested tippuebersicht matchday {RequestedMatchday}, but page displayed {DisplayedMatc
 823        }
 824
 0825        var outcomes = ParseTippuebersichtMatchdayOutcomes(document, displayedMatchday)
 0826            .AsReadOnly();
 827
 0828        var cacheOptions = new MemoryCacheEntryOptions
 0829        {
 0830            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
 0831            SlidingExpiration = TimeSpan.FromMinutes(5)
 0832        };
 833
 0834        _cache.Set(cacheKey, outcomes, cacheOptions);
 0835        return outcomes;
 0836    }
 837
 838    /// <inheritdoc />
 839    public async Task<(List<MatchResult> homeTeamHomeHistory, List<MatchResult> awayTeamAwayHistory)> GetHomeAwayHistory
 840    {
 841        try
 842        {
 843            // First, get the tippabgabe page to find the link to spielinfos
 1844            var tippabgabeUrl = $"{community}/tippabgabe";
 1845            var response = await _httpClient.GetAsync(tippabgabeUrl);
 846
 1847            if (!response.IsSuccessStatusCode)
 848            {
 1849                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1850                return (new List<MatchResult>(), new List<MatchResult>());
 851            }
 852
 1853            var content = await response.Content.ReadAsStringAsync();
 1854            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 855
 856            // Find the "Tippabgabe mit Spielinfos" link
 1857            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1858            if (spielinfoLink == null)
 859            {
 1860                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1861                return (new List<MatchResult>(), new List<MatchResult>());
 862            }
 863
 1864            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1865            if (string.IsNullOrEmpty(spielinfoUrl))
 866            {
 0867                _logger.LogWarning("Spielinfo link has no href attribute");
 0868                return (new List<MatchResult>(), new List<MatchResult>());
 869            }
 870
 871            // Make URL absolute if it's relative
 1872            if (spielinfoUrl.StartsWith("/"))
 873            {
 1874                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 875            }
 876
 877            // Navigate through all matches using the right arrow navigation
 1878            var currentUrl = spielinfoUrl;
 879
 1880            while (!string.IsNullOrEmpty(currentUrl))
 881            {
 882                try
 883                {
 884                    // Add ansicht=2 parameter for home/away history
 1885                    var homeAwayUrl = currentUrl.Contains('?')
 1886                        ? $"{currentUrl}&ansicht=2"
 1887                        : $"{currentUrl}?ansicht=2";
 888
 1889                    var spielinfoResponse = await _httpClient.GetAsync(homeAwayUrl);
 1890                    if (!spielinfoResponse.IsSuccessStatusCode)
 891                    {
 1892                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", homeAwayUrl, s
 1893                        break;
 894                    }
 895
 1896                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1897                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 898
 899                    // Check if this page contains our match
 1900                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 901                    {
 902                        // Extract home team home history
 1903                        var homeTeamHomeHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoHeim");
 904
 905                        // Extract away team away history
 1906                        var awayTeamAwayHistory = ExtractTeamHistory(spielinfoDocument, "spielinfoGast");
 907
 1908                        return (homeTeamHomeHistory, awayTeamAwayHistory);
 909                    }
 910
 911                    // Find the next match link (right arrow)
 1912                    var nextLink = FindNextMatchLink(spielinfoDocument);
 1913                    if (nextLink != null)
 914                    {
 1915                        currentUrl = nextLink;
 1916                        if (currentUrl.StartsWith("/"))
 917                        {
 1918                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 919                        }
 920                    }
 921                    else
 922                    {
 923                        // No more matches
 1924                        break;
 925                    }
 1926                }
 0927                catch (Exception ex)
 928                {
 0929                    _logger.LogError(ex, "Error processing spielinfo page for home/away history: {CurrentUrl}", currentU
 0930                    break;
 931                }
 932            }
 933
 1934            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 1935            return (new List<MatchResult>(), new List<MatchResult>());
 936        }
 0937        catch (Exception ex)
 938        {
 0939            _logger.LogError(ex, "Exception in GetHomeAwayHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 0940            return (new List<MatchResult>(), new List<MatchResult>());
 941        }
 1942    }
 943
 944    /// <inheritdoc />
 945    public async Task<List<MatchResult>> GetHeadToHeadHistoryAsync(string community, string homeTeam, string awayTeam)
 946    {
 947        try
 948        {
 949            // First, get the tippabgabe page to find the link to spielinfos
 1950            var tippabgabeUrl = $"{community}/tippabgabe";
 1951            var response = await _httpClient.GetAsync(tippabgabeUrl);
 952
 1953            if (!response.IsSuccessStatusCode)
 954            {
 1955                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1956                return new List<MatchResult>();
 957            }
 958
 1959            var content = await response.Content.ReadAsStringAsync();
 1960            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 961
 962            // Find the "Tippabgabe mit Spielinfos" link
 1963            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1964            if (spielinfoLink == null)
 965            {
 1966                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1967                return new List<MatchResult>();
 968            }
 969
 1970            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1971            if (string.IsNullOrEmpty(spielinfoUrl))
 972            {
 0973                _logger.LogWarning("Spielinfo link has no href attribute");
 0974                return new List<MatchResult>();
 975            }
 976
 977            // Make URL absolute if it's relative
 1978            if (spielinfoUrl.StartsWith("/"))
 979            {
 1980                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 981            }
 982
 983            // Navigate through all matches using the right arrow navigation
 1984            var currentUrl = spielinfoUrl;
 985
 1986            while (!string.IsNullOrEmpty(currentUrl))
 987            {
 988                try
 989                {
 990                    // Add ansicht=3 parameter for head-to-head history
 1991                    var headToHeadUrl = currentUrl.Contains('?')
 1992                        ? $"{currentUrl}&ansicht=3"
 1993                        : $"{currentUrl}?ansicht=3";
 994
 1995                    var spielinfoResponse = await _httpClient.GetAsync(headToHeadUrl);
 1996                    if (!spielinfoResponse.IsSuccessStatusCode)
 997                    {
 1998                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", headToHeadUrl,
 1999                        break;
 1000                    }
 1001
 11002                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 11003                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1004
 1005                    // Check if this page contains our match
 11006                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1007                    {
 1008                        // Extract head-to-head history
 11009                        return ExtractTeamHistory(spielinfoDocument, "spielinfoDirekterVergleich");
 1010                    }
 1011
 1012                    // Find the next match link (right arrow)
 11013                    var nextLink = FindNextMatchLink(spielinfoDocument);
 11014                    if (nextLink != null)
 1015                    {
 11016                        currentUrl = nextLink;
 11017                        if (currentUrl.StartsWith("/"))
 1018                        {
 11019                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1020                        }
 1021                    }
 1022                    else
 1023                    {
 1024                        // No more matches
 11025                        break;
 1026                    }
 11027                }
 01028                catch (Exception ex)
 1029                {
 01030                    _logger.LogError(ex, "Error processing spielinfo page for head-to-head history: {CurrentUrl}", curre
 01031                    break;
 1032                }
 1033            }
 1034
 11035            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 11036            return new List<MatchResult>();
 1037        }
 01038        catch (Exception ex)
 1039        {
 01040            _logger.LogError(ex, "Exception in GetHeadToHeadHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam, awayTe
 01041            return new List<MatchResult>();
 1042        }
 11043    }
 1044
 1045    /// <inheritdoc />
 1046    public async Task<List<HeadToHeadResult>> GetHeadToHeadDetailedHistoryAsync(string community, string homeTeam, strin
 1047    {
 1048        try
 1049        {
 1050            // First, get the tippabgabe page to find the link to spielinfos
 11051            var tippabgabeUrl = $"{community}/tippabgabe";
 11052            var response = await _httpClient.GetAsync(tippabgabeUrl);
 1053
 11054            if (!response.IsSuccessStatusCode)
 1055            {
 11056                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 11057                return new List<HeadToHeadResult>();
 1058            }
 1059
 11060            var content = await response.Content.ReadAsStringAsync();
 11061            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1062
 1063            // Find the "Tippabgabe mit Spielinfos" link
 11064            var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 11065            if (spielinfoLink == null)
 1066            {
 11067                _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 11068                return new List<HeadToHeadResult>();
 1069            }
 1070
 11071            var spielinfoUrl = spielinfoLink.GetAttribute("href");
 11072            if (string.IsNullOrEmpty(spielinfoUrl))
 1073            {
 01074                _logger.LogWarning("Spielinfo link has no href attribute");
 01075                return new List<HeadToHeadResult>();
 1076            }
 1077
 1078            // Make URL absolute if it's relative
 11079            if (spielinfoUrl.StartsWith("/"))
 1080            {
 11081                spielinfoUrl = spielinfoUrl.Substring(1); // Remove leading slash
 1082            }
 1083
 1084            // Navigate through all matches using the right arrow navigation
 11085            var currentUrl = spielinfoUrl;
 1086
 11087            while (!string.IsNullOrEmpty(currentUrl))
 1088            {
 1089                try
 1090                {
 1091                    // Append ansicht=3 to get head-to-head view
 11092                    var urlWithAnsicht = currentUrl.Contains('?') ? $"{currentUrl}&ansicht=3" : $"{currentUrl}?ansicht=3
 11093                    var spielinfoResponse = await _httpClient.GetAsync(urlWithAnsicht);
 1094
 11095                    if (!spielinfoResponse.IsSuccessStatusCode)
 1096                    {
 11097                        _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}", urlWithAnsicht
 11098                        break;
 1099                    }
 1100
 11101                    var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 11102                    var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1103
 1104                    // Check if this page contains our match
 11105                    if (IsMatchOnPage(spielinfoDocument, homeTeam, awayTeam))
 1106                    {
 1107                        // Extract head-to-head history from this page
 11108                        return ExtractHeadToHeadHistory(spielinfoDocument);
 1109                    }
 1110
 1111                    // Find the next match link (right arrow)
 11112                    var nextLink = FindNextMatchLink(spielinfoDocument);
 11113                    if (nextLink != null)
 1114                    {
 11115                        currentUrl = nextLink;
 11116                        if (currentUrl.StartsWith("/"))
 1117                        {
 11118                            currentUrl = currentUrl.Substring(1); // Remove leading slash
 1119                        }
 1120                    }
 1121                    else
 1122                    {
 11123                        break;
 1124                    }
 11125                }
 01126                catch (Exception ex)
 1127                {
 01128                    _logger.LogWarning(ex, "Error processing spielinfo page: {Url}", currentUrl);
 01129                    break;
 1130                }
 1131            }
 1132
 11133            _logger.LogWarning("Could not find match {HomeTeam} vs {AwayTeam} in spielinfo pages", homeTeam, awayTeam);
 11134            return new List<HeadToHeadResult>();
 1135        }
 01136        catch (Exception ex)
 1137        {
 01138            _logger.LogError(ex, "Exception in GetHeadToHeadDetailedHistoryAsync for {HomeTeam} vs {AwayTeam}", homeTeam
 01139            return new List<HeadToHeadResult>();
 1140        }
 11141    }
 1142    private bool IsMatchOnPage(IDocument document, string homeTeam, string awayTeam)
 1143    {
 1144        try
 1145        {
 1146            // Look for the match in the tippabgabe table
 11147            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 1148
 11149            foreach (var row in matchRows)
 1150            {
 11151                var cells = row.QuerySelectorAll("td");
 11152                if (cells.Length >= 3)
 1153                {
 11154                    var pageHomeTeam = cells[1].TextContent?.Trim() ?? "";
 11155                    var pageAwayTeam = cells[2].TextContent?.Trim() ?? "";
 1156
 11157                    if (pageHomeTeam == homeTeam && pageAwayTeam == awayTeam)
 1158                    {
 11159                        return true;
 1160                    }
 1161                }
 1162            }
 1163
 11164            return false;
 1165        }
 01166        catch (Exception ex)
 1167        {
 01168            _logger.LogDebug(ex, "Error checking if match is on page");
 01169            return false;
 1170        }
 11171    }
 1172
 1173    private MatchWithHistory? ExtractMatchWithHistoryFromSpielinfoPage(IDocument document, int matchday)
 1174    {
 1175        try
 1176        {
 1177            // Extract match information from the tippabgabe table
 1178            // Look for all rows in the table, not just the first one
 11179            var matchRows = document.QuerySelectorAll("table.tippabgabe tbody tr");
 11180            if (matchRows.Length == 0)
 1181            {
 01182                _logger.LogWarning("Could not find any match rows in tippabgabe table on spielinfo page");
 01183                return null;
 1184            }
 1185
 11186            _logger.LogDebug("Found {RowCount} rows in tippabgabe table", matchRows.Length);
 1187
 1188            // Find the row that contains match data (has input fields for betting)
 11189            IElement? matchRow = null;
 11190            foreach (var row in matchRows)
 1191            {
 11192                var rowCells = row.QuerySelectorAll("td");
 11193                if (rowCells.Length >= 4)
 1194                {
 1195                    // Check if this row has betting inputs (indicates it's the match row)
 11196                    var bettingInputs = rowCells[3].QuerySelectorAll("input[type='text']");
 11197                    if (bettingInputs.Length >= 2)
 1198                    {
 11199                        matchRow = row;
 11200                        break;
 1201                    }
 1202                }
 1203            }
 1204
 11205            if (matchRow == null)
 1206            {
 11207                _logger.LogWarning("Could not find match row with betting inputs in tippabgabe table");
 11208                return null;
 1209            }
 1210
 11211            var cells = matchRow.QuerySelectorAll("td");
 11212            if (cells.Length < 4)
 1213            {
 01214                _logger.LogWarning("Match row does not have enough cells");
 01215                return null;
 1216            }
 1217
 11218            _logger.LogDebug("Found {CellCount} cells in match row", cells.Length);
 11219            for (int i = 0; i < Math.Min(cells.Length, 5); i++)
 1220            {
 11221                _logger.LogDebug("Cell[{Index}]: '{Content}' (Class: '{Class}')", i, cells[i].TextContent?.Trim(), cells
 1222            }
 1223
 11224            var timeText = cells[0].TextContent?.Trim() ?? "";
 11225            var homeTeam = cells[1].TextContent?.Trim() ?? "";
 11226            var awayTeam = cells[2].TextContent?.Trim() ?? "";
 1227
 11228            _logger.LogDebug("Extracted from spielinfo page - Time: '{TimeText}', Home: '{HomeTeam}', Away: '{AwayTeam}'
 1229
 11230            if (string.IsNullOrEmpty(homeTeam) || string.IsNullOrEmpty(awayTeam))
 1231            {
 01232                _logger.LogWarning("Could not extract team names from match table");
 01233                return null;
 1234            }
 1235
 1236            // Check if match is cancelled ("Abgesagt" in German)
 1237            // Note: On spielinfo pages, cancelled matches may still show - process them with IsCancelled flag
 11238            var isCancelled = IsCancelledTimeText(timeText);
 11239            if (isCancelled)
 1240            {
 01241                _logger.LogWarning(
 01242                    "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt) on spielinfo page. " +
 01243                    "Using current time as fallback since spielinfo doesn't provide time inheritance context.",
 01244                    homeTeam, awayTeam);
 1245            }
 1246
 11247            var startsAt = ParseMatchDateTime(timeText);
 11248            var match = new Match(homeTeam, awayTeam, startsAt, matchday, isCancelled);
 1249
 1250            // Extract home team history
 11251            var homeTeamHistory = ExtractTeamHistory(document, "spielinfoHeim");
 1252
 1253            // Extract away team history
 11254            var awayTeamHistory = ExtractTeamHistory(document, "spielinfoGast");
 1255
 11256            return new MatchWithHistory(match, homeTeamHistory, awayTeamHistory);
 1257        }
 01258        catch (Exception ex)
 1259        {
 01260            _logger.LogError(ex, "Error extracting match with history from spielinfo page");
 01261            return null;
 1262        }
 11263    }
 1264
 1265    private List<MatchResult> ExtractTeamHistory(IDocument document, string tableClass)
 1266    {
 11267        var results = new List<MatchResult>();
 1268
 1269        try
 1270        {
 11271            var table = document.QuerySelector($"table.{tableClass} tbody");
 11272            if (table == null)
 1273            {
 01274                _logger.LogDebug("Could not find team history table with class: {TableClass}", tableClass);
 01275                return results;
 1276            }
 1277
 11278            var rows = table.QuerySelectorAll("tr");
 11279            foreach (var row in rows)
 1280            {
 1281                try
 1282                {
 11283                    var cells = row.QuerySelectorAll("td");
 1284
 1285                    // Handle different table formats
 1286                    string competition, homeTeam, awayTeam;
 11287                    var resultCell = cells.Last(); // Result is always in the last cell
 11288                    var homeGoals = (int?)null;
 11289                    var awayGoals = (int?)null;
 11290                    var outcome = MatchOutcome.Pending;
 11291                    string? annotation = null;
 1292
 11293                    if (tableClass == "spielinfoDirekterVergleich")
 1294                    {
 1295                        // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 11296                        if (cells.Length < 6)
 01297                            continue;
 1298
 11299                        competition = $"{cells[0].TextContent?.Trim()} {cells[1].TextContent?.Trim()}";
 11300                        homeTeam = cells[3].TextContent?.Trim() ?? "";
 11301                        awayTeam = cells[4].TextContent?.Trim() ?? "";
 1302                    }
 1303                    else
 1304                    {
 1305                        // Standard format: Competition | Home | Away | Result
 11306                        if (cells.Length < 4)
 01307                            continue;
 1308
 11309                        competition = cells[0].TextContent?.Trim() ?? "";
 11310                        homeTeam = cells[1].TextContent?.Trim() ?? "";
 11311                        awayTeam = cells[2].TextContent?.Trim() ?? "";
 1312                    }
 1313
 1314                    // Parse the score from the result cell
 11315                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 11316                    if (scoreElements.Length >= 2)
 1317                    {
 11318                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 11319                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1320
 11321                        if (homeScoreText != "-" && awayScoreText != "-")
 1322                        {
 11323                            if (int.TryParse(homeScoreText, out var homeScore) && int.TryParse(awayScoreText, out var aw
 1324                            {
 11325                                homeGoals = homeScore;
 11326                                awayGoals = awayScore;
 1327
 1328                                // Determine outcome from team's perspective based on CSS classes
 11329                                var homeTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[3] : cells[1];
 11330                                var awayTeamCell = tableClass == "spielinfoDirekterVergleich" ? cells[4] : cells[2];
 1331
 11332                                var isHomeTeam = homeTeamCell.ClassList.Contains("sieg") || homeTeamCell.ClassList.Conta
 11333                                var isAwayTeam = awayTeamCell.ClassList.Contains("sieg") || awayTeamCell.ClassList.Conta
 1334
 11335                                if (isHomeTeam)
 1336                                {
 11337                                    outcome = homeScore > awayScore ? MatchOutcome.Win :
 11338                                             homeScore < awayScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1339                                }
 11340                                else if (isAwayTeam)
 1341                                {
 11342                                    outcome = awayScore > homeScore ? MatchOutcome.Win :
 11343                                             awayScore < homeScore ? MatchOutcome.Loss : MatchOutcome.Draw;
 1344                                }
 1345                                else
 1346                                {
 1347                                    // Fallback: determine from score (neutral perspective)
 11348                                    outcome = homeScore == awayScore ? MatchOutcome.Draw :
 11349                                             homeScore > awayScore ? MatchOutcome.Win : MatchOutcome.Loss;
 1350                                }
 1351                            }
 1352                        }
 1353                    }
 1354
 1355                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 11356                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 11357                    if (annotationElement != null)
 1358                    {
 11359                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1360                    }
 1361
 11362                    var matchResult = new MatchResult(competition, homeTeam, awayTeam, homeGoals, awayGoals, outcome, an
 11363                    results.Add(matchResult);
 11364                }
 01365                catch (Exception ex)
 1366                {
 01367                    _logger.LogDebug(ex, "Error parsing team history row");
 01368                    continue;
 1369                }
 1370            }
 11371        }
 01372        catch (Exception ex)
 1373        {
 01374            _logger.LogError(ex, "Error extracting team history for table class: {TableClass}", tableClass);
 01375        }
 1376
 11377        return results;
 01378    }
 1379
 1380    private List<HeadToHeadResult> ExtractHeadToHeadHistory(IDocument document)
 1381    {
 11382        var results = new List<HeadToHeadResult>();
 1383
 1384        try
 1385        {
 11386            var table = document.QuerySelector("table.spielinfoDirekterVergleich tbody");
 11387            if (table == null)
 1388            {
 01389                _logger.LogDebug("Could not find head-to-head table with class: spielinfoDirekterVergleich");
 01390                return results;
 1391            }
 1392
 11393            var rows = table.QuerySelectorAll("tr");
 11394            foreach (var row in rows)
 1395            {
 1396                try
 1397                {
 11398                    var cells = row.QuerySelectorAll("td");
 1399
 1400                    // Direct comparison format: Season | Matchday | Date | Home | Away | Result
 11401                    if (cells.Length < 6)
 01402                        continue;
 1403
 11404                    var league = cells[0].TextContent?.Trim() ?? "";
 11405                    var matchday = cells[1].TextContent?.Trim() ?? "";
 11406                    var playedAt = cells[2].TextContent?.Trim() ?? "";
 11407                    var homeTeam = cells[3].TextContent?.Trim() ?? "";
 11408                    var awayTeam = cells[4].TextContent?.Trim() ?? "";
 1409
 1410                    // Extract score from the result cell
 11411                    var resultCell = cells[5];
 11412                    var score = "";
 11413                    string? annotation = null;
 1414
 11415                    var scoreElements = resultCell.QuerySelectorAll(".kicktipp-heim, .kicktipp-gast");
 11416                    if (scoreElements.Length >= 2)
 1417                    {
 11418                        var homeScoreText = scoreElements[0].TextContent?.Trim() ?? "";
 11419                        var awayScoreText = scoreElements[1].TextContent?.Trim() ?? "";
 1420
 11421                        if (homeScoreText != "-" && awayScoreText != "-")
 1422                        {
 11423                            score = $"{homeScoreText}:{awayScoreText}";
 1424                        }
 1425                    }
 1426
 1427                    // Extract annotation if present (e.g., "n.E." for penalty shootout)
 11428                    var annotationElement = resultCell.QuerySelector(".kicktipp-zusatz");
 11429                    if (annotationElement != null)
 1430                    {
 11431                        annotation = ExpandAnnotation(annotationElement.TextContent?.Trim());
 1432                    }
 1433
 11434                    var headToHeadResult = new HeadToHeadResult(league, matchday, playedAt, homeTeam, awayTeam, score, a
 11435                    results.Add(headToHeadResult);
 11436                }
 01437                catch (Exception ex)
 1438                {
 01439                    _logger.LogDebug(ex, "Error parsing head-to-head row");
 01440                    continue;
 1441                }
 1442            }
 11443        }
 01444        catch (Exception ex)
 1445        {
 01446            _logger.LogError(ex, "Error extracting head-to-head history");
 01447        }
 1448
 11449        return results;
 01450    }
 1451
 1452    private string? FindNextMatchLink(IDocument document)
 1453    {
 1454        try
 1455        {
 1456            // Look for the right arrow button in the match navigation
 11457            var nextButton = document.QuerySelector(".prevnextNext a");
 11458            if (nextButton == null)
 1459            {
 11460                _logger.LogDebug("No next match button found");
 11461                return null;
 1462            }
 1463
 1464            // Check if the button is disabled
 11465            var parentDiv = nextButton.ParentElement;
 11466            if (parentDiv?.ClassList.Contains("disabled") == true)
 1467            {
 11468                _logger.LogDebug("Next match button is disabled - reached end of matches");
 11469                return null;
 1470            }
 1471
 11472            var href = nextButton.GetAttribute("href");
 11473            if (string.IsNullOrEmpty(href))
 1474            {
 01475                _logger.LogDebug("Next match button has no href");
 01476                return null;
 1477            }
 1478
 11479            _logger.LogDebug("Found next match link: {Href}", href);
 11480            return href;
 1481        }
 01482        catch (Exception ex)
 1483        {
 01484            _logger.LogError(ex, "Error finding next match link");
 01485            return null;
 1486        }
 11487    }
 1488
 1489    private ZonedDateTime ParseMatchDateTime(string timeText)
 1490    {
 1491        try
 1492        {
 1493            // Handle empty or null time text
 1494            // Use MinValue to ensure database key consistency and prevent orphaned predictions
 1495            // See docs/features/cancelled-matches.md for design rationale
 11496            if (string.IsNullOrWhiteSpace(timeText))
 1497            {
 11498                _logger.LogWarning("Match time text is empty, using MinValue for database consistency");
 11499                return DateTimeOffset.MinValue.ToZonedDateTime();
 1500            }
 1501
 1502            // Expected format: "22.08.25 20:30"
 11503            _logger.LogDebug("Attempting to parse time: '{TimeText}'", timeText);
 11504            if (DateTime.TryParseExact(timeText, "dd.MM.yy HH:mm", null, System.Globalization.DateTimeStyles.None, out v
 1505            {
 11506                _logger.LogDebug("Successfully parsed time: {DateTime}", dateTime);
 1507                // Convert to DateTimeOffset and then to ZonedDateTime
 1508                // Assume Central European Time (Germany)
 11509                var dateTimeOffset = new DateTimeOffset(dateTime, TimeSpan.FromHours(1)); // CET offset
 11510                return dateTimeOffset.ToZonedDateTime();
 1511            }
 1512
 1513            // Fallback to MinValue if parsing fails - ensures database key consistency
 1514            // and prevents orphaned predictions from being created with varying timestamps
 1515            // See docs/features/cancelled-matches.md for design rationale
 01516            _logger.LogWarning("Could not parse match time: '{TimeText}', using MinValue for database consistency", time
 01517            return DateTimeOffset.MinValue.ToZonedDateTime();
 1518        }
 01519        catch (Exception ex)
 1520        {
 01521            _logger.LogError(ex, "Error parsing match time '{TimeText}'", timeText);
 01522            return DateTimeOffset.MinValue.ToZonedDateTime();
 1523        }
 11524    }
 1525
 1526    /// <summary>
 1527    /// Determines if the given time text indicates a cancelled match.
 1528    /// </summary>
 1529    /// <param name="timeText">The time text from the Kicktipp page.</param>
 1530    /// <returns>True if the match is cancelled ("Abgesagt" in German), false otherwise.</returns>
 1531    /// <remarks>
 1532    /// <para>
 1533    /// Cancelled matches on Kicktipp display "Abgesagt" instead of a date/time in the schedule.
 1534    /// These matches can still receive predictions, so we continue processing them rather than skipping.
 1535    /// </para>
 1536    /// <para>
 1537    /// <b>Design Decision:</b> We treat "Abgesagt" similar to an empty time cell and inherit the
 1538    /// previous valid time. This preserves database key consistency since the composite key
 1539    /// (HomeTeam, AwayTeam, StartsAt, ...) must remain stable across prediction operations.
 1540    /// </para>
 1541    /// <para>
 1542    /// See <c>docs/features/cancelled-matches.md</c> for complete design rationale.
 1543    /// </para>
 1544    /// </remarks>
 1545    private static bool IsCancelledTimeText(string timeText)
 1546    {
 11547        return string.Equals(timeText, "Abgesagt", StringComparison.OrdinalIgnoreCase);
 1548    }
 1549
 1550    private async Task<IDocument?> GetTippuebersichtDocumentAsync(string community, int? matchday)
 1551    {
 1552        try
 1553        {
 01554            var url = matchday.HasValue
 01555                ? $"{community}/tippuebersicht?spieltagIndex={matchday.Value}"
 01556                : $"{community}/tippuebersicht";
 1557
 01558            var response = await _httpClient.GetAsync(url);
 01559            if (!response.IsSuccessStatusCode)
 1560            {
 01561                _logger.LogError("Failed to fetch tippuebersicht page {Url}. Status: {StatusCode}", url, response.Status
 01562                return null;
 1563            }
 1564
 01565            var content = await response.Content.ReadAsStringAsync();
 01566            return await _browsingContext.OpenAsync(req => req.Content(content));
 1567        }
 01568        catch (Exception ex)
 1569        {
 01570            _logger.LogError(ex, "Error fetching tippuebersicht page for {Community} matchday {Matchday}", community, ma
 01571            return null;
 1572        }
 01573    }
 1574
 1575    private List<CollectedMatchOutcome> ParseTippuebersichtMatchdayOutcomes(IDocument document, int matchday)
 1576    {
 01577        var outcomes = new List<CollectedMatchOutcome>();
 1578
 01579        var matchTable = document.QuerySelector("#spielplanSpiele tbody");
 01580        if (matchTable == null)
 1581        {
 01582            _logger.LogWarning("Could not find tippuebersicht match table for matchday {Matchday}", matchday);
 01583            return outcomes;
 1584        }
 1585
 01586        var matchRows = matchTable.QuerySelectorAll("tr");
 01587        string lastValidTimeText = string.Empty;
 1588
 01589        foreach (var row in matchRows)
 1590        {
 1591            try
 1592            {
 01593                var cells = row.QuerySelectorAll("td");
 01594                if (cells.Length < 4)
 1595                {
 01596                    continue;
 1597                }
 1598
 01599                var timeText = cells[0].TextContent?.Trim() ?? string.Empty;
 01600                var homeTeam = cells[1].TextContent?.Trim() ?? string.Empty;
 01601                var awayTeam = cells[2].TextContent?.Trim() ?? string.Empty;
 1602
 01603                if (string.IsNullOrWhiteSpace(homeTeam) || string.IsNullOrWhiteSpace(awayTeam))
 1604                {
 01605                    continue;
 1606                }
 1607
 01608                var isCancelled = IsCancelledTimeText(timeText);
 01609                if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 1610                {
 01611                    if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 1612                    {
 01613                        timeText = lastValidTimeText;
 1614                    }
 1615                }
 1616                else
 1617                {
 01618                    lastValidTimeText = timeText;
 1619                }
 1620
 01621                var startsAt = ParseMatchDateTime(timeText);
 01622                var (homeGoals, awayGoals, availability) = ParseMatchOutcome(cells[3]);
 01623                var tippSpielId = ExtractTippSpielId(row.GetAttribute("data-url"));
 1624
 01625                outcomes.Add(new CollectedMatchOutcome(
 01626                    homeTeam,
 01627                    awayTeam,
 01628                    startsAt,
 01629                    matchday,
 01630                    homeGoals,
 01631                    awayGoals,
 01632                    availability,
 01633                    tippSpielId));
 01634            }
 01635            catch (Exception ex)
 1636            {
 01637                _logger.LogWarning(ex, "Error parsing tippuebersicht row for matchday {Matchday}", matchday);
 01638            }
 1639        }
 1640
 01641        _logger.LogInformation("Parsed {MatchCount} tippuebersicht matches for matchday {Matchday}", outcomes.Count, mat
 01642        return outcomes;
 1643    }
 1644
 1645    private static (int? homeGoals, int? awayGoals, MatchOutcomeAvailability availability) ParseMatchOutcome(IElement re
 1646    {
 01647        var homeGoalText = resultCell.QuerySelector(".kicktipp-heim")?.TextContent?.Trim();
 01648        var awayGoalText = resultCell.QuerySelector(".kicktipp-gast")?.TextContent?.Trim();
 1649
 01650        if (int.TryParse(homeGoalText, out var homeGoals) && int.TryParse(awayGoalText, out var awayGoals))
 1651        {
 01652            return (homeGoals, awayGoals, MatchOutcomeAvailability.Completed);
 1653        }
 1654
 01655        return (null, null, MatchOutcomeAvailability.Pending);
 1656    }
 1657
 1658    private static string? ExtractTippSpielId(string? dataUrl)
 1659    {
 01660        if (string.IsNullOrWhiteSpace(dataUrl))
 1661        {
 01662            return null;
 1663        }
 1664
 01665        var match = Regex.Match(dataUrl, @"(?:\?|&)tippspielId=(\d+)");
 01666        return match.Success ? match.Groups[1].Value : null;
 1667    }
 1668
 1669    /// <inheritdoc />
 1670    public async Task<Dictionary<Match, BetPrediction?>> GetPlacedPredictionsAsync(string community)
 1671    {
 1672        try
 1673        {
 11674            var url = $"{community}/tippabgabe";
 11675            var response = await _httpClient.GetAsync(url);
 1676
 11677            if (!response.IsSuccessStatusCode)
 1678            {
 11679                _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 11680                return new Dictionary<Match, BetPrediction?>();
 1681            }
 1682
 11683            var content = await response.Content.ReadAsStringAsync();
 11684            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1685
 11686            var placedPredictions = new Dictionary<Match, BetPrediction?>();
 1687
 1688            // Extract matchday from the page
 11689            var currentMatchday = ExtractMatchdayFromPage(document);
 11690            _logger.LogDebug("Extracted matchday for placed predictions: {Matchday}", currentMatchday);
 1691
 1692            // Parse matches from the tippabgabe table
 11693            var matchTable = document.QuerySelector("#tippabgabeSpiele tbody");
 11694            if (matchTable == null)
 1695            {
 11696                _logger.LogWarning("Could not find tippabgabe table");
 11697                return placedPredictions;
 1698            }
 1699
 11700            var matchRows = matchTable.QuerySelectorAll("tr");
 11701            _logger.LogDebug("Found {MatchRowCount} potential match rows", matchRows.Length);
 1702
 11703            string lastValidTimeText = "";  // Track the last valid date/time for inheritance
 1704
 11705            foreach (var row in matchRows)
 1706            {
 1707                try
 1708                {
 11709                    var cells = row.QuerySelectorAll("td");
 11710                    if (cells.Length >= 4)
 1711                    {
 1712                        // Extract match details from table cells
 11713                        var timeText = cells[0].TextContent?.Trim() ?? "";
 11714                        var homeTeam = cells[1].TextContent?.Trim() ?? "";
 11715                        var awayTeam = cells[2].TextContent?.Trim() ?? "";
 1716
 11717                        _logger.LogDebug("Raw time text for {HomeTeam} vs {AwayTeam}: '{TimeText}'", homeTeam, awayTeam,
 1718
 1719                        // Check if match is cancelled ("Abgesagt" in German)
 1720                        // Cancelled matches still accept predictions on Kicktipp, so we process them.
 1721                        // See docs/features/cancelled-matches.md for design rationale.
 11722                        var isCancelled = IsCancelledTimeText(timeText);
 1723
 1724                        // Handle date inheritance: if timeText is empty or cancelled, use the last valid time
 1725                        // This preserves database key consistency (startsAt is part of the composite key)
 11726                        if (string.IsNullOrWhiteSpace(timeText) || isCancelled)
 1727                        {
 11728                            if (!string.IsNullOrWhiteSpace(lastValidTimeText))
 1729                            {
 11730                                if (isCancelled)
 1731                                {
 11732                                    _logger.LogWarning(
 11733                                        "Match {HomeTeam} vs {AwayTeam} is cancelled (Abgesagt). Using inherited time '{
 11734                                        "Predictions can still be placed but may need to be re-evaluated when the match 
 11735                                        homeTeam, awayTeam, lastValidTimeText);
 1736                                }
 1737                                else
 1738                                {
 11739                                    _logger.LogDebug("Using inherited time for {HomeTeam} vs {AwayTeam}: '{InheritedTime
 1740                                }
 11741                                timeText = lastValidTimeText;
 1742                            }
 1743                            else
 1744                            {
 11745                                _logger.LogWarning("No previous valid time to inherit for {HomeTeam} vs {AwayTeam}{Cance
 11746                                    homeTeam, awayTeam, isCancelled ? " (cancelled match)" : "");
 1747                            }
 1748                        }
 1749                        else
 1750                        {
 1751                            // Update the last valid time for future inheritance
 11752                            lastValidTimeText = timeText;
 11753                            _logger.LogDebug("Updated last valid time to: '{TimeText}'", timeText);
 1754                        }
 1755
 1756                        // Look for betting inputs to get placed predictions
 11757                        var bettingInputs = cells[3].QuerySelectorAll("input[type='text']");
 11758                        if (bettingInputs.Length >= 2)
 1759                        {
 11760                            var homeInput = bettingInputs[0] as IHtmlInputElement;
 11761                            var awayInput = bettingInputs[1] as IHtmlInputElement;
 1762
 1763                            // Parse the date/time
 11764                            var startsAt = ParseMatchDateTime(timeText);
 11765                            var match = new Match(homeTeam, awayTeam, startsAt, currentMatchday, isCancelled);
 1766
 1767                            // Check if predictions are placed (inputs have values)
 11768                            var homeValue = homeInput?.Value?.Trim();
 11769                            var awayValue = awayInput?.Value?.Trim();
 1770
 11771                            BetPrediction? prediction = null;
 11772                            if (!string.IsNullOrEmpty(homeValue) && !string.IsNullOrEmpty(awayValue))
 1773                            {
 11774                                if (int.TryParse(homeValue, out var homeGoals) && int.TryParse(awayValue, out var awayGo
 1775                                {
 11776                                    prediction = new BetPrediction(homeGoals, awayGoals);
 11777                                    _logger.LogDebug("Found placed prediction: {HomeTeam} vs {AwayTeam} = {Prediction}",
 1778                                }
 1779                                else
 1780                                {
 11781                                    _logger.LogWarning("Could not parse prediction values for {HomeTeam} vs {AwayTeam}: 
 1782                                }
 1783                            }
 1784                            else
 1785                            {
 11786                                _logger.LogDebug("No prediction placed for {HomeTeam} vs {AwayTeam}", homeTeam, awayTeam
 1787                            }
 1788
 11789                            placedPredictions[match] = prediction;
 1790                        }
 1791                    }
 11792                }
 01793                catch (Exception ex)
 1794                {
 01795                    _logger.LogWarning(ex, "Error parsing match row");
 01796                    continue;
 1797                }
 1798            }
 1799
 11800            _logger.LogInformation("Successfully parsed {MatchCount} matches with {PlacedCount} placed predictions",
 11801                placedPredictions.Count, placedPredictions.Values.Count(p => p != null));
 11802            return placedPredictions;
 1803        }
 01804        catch (Exception ex)
 1805        {
 01806            _logger.LogError(ex, "Exception in GetPlacedPredictionsAsync");
 01807            return new Dictionary<Match, BetPrediction?>();
 1808        }
 11809    }
 1810
 1811    private int ExtractMatchdayFromPage(IDocument document)
 1812    {
 1813        try
 1814        {
 1815            // Try to extract from the navigation title (e.g., "1. Spieltag")
 11816            var titleElement = document.QuerySelector(".prevnextTitle a");
 11817            if (titleElement != null)
 1818            {
 11819                var titleText = titleElement.TextContent?.Trim();
 11820                if (!string.IsNullOrEmpty(titleText))
 1821                {
 1822                    // Extract number from text like "1. Spieltag"
 11823                    var match = System.Text.RegularExpressions.Regex.Match(titleText, @"(\d+)\.\s*Spieltag");
 11824                    if (match.Success && int.TryParse(match.Groups[1].Value, out var matchday))
 1825                    {
 11826                        _logger.LogDebug("Extracted matchday from title: {Matchday}", matchday);
 11827                        return matchday;
 1828                    }
 1829                }
 1830            }
 1831
 1832            // Fallback: try to extract from hidden input
 11833            var spieltagInput = document.QuerySelector("input[name='spieltagIndex']") as IHtmlInputElement;
 11834            if (spieltagInput?.Value != null && int.TryParse(spieltagInput.Value, out var matchdayFromInput))
 1835            {
 11836                _logger.LogDebug("Extracted matchday from hidden input: {Matchday}", matchdayFromInput);
 11837                return matchdayFromInput;
 1838            }
 1839
 11840            _logger.LogWarning("Could not extract matchday from page, defaulting to 1");
 11841            return 1;
 1842        }
 01843        catch (Exception ex)
 1844        {
 01845            _logger.LogError(ex, "Error extracting matchday from page, defaulting to 1");
 01846            return 1;
 1847        }
 11848    }
 1849
 1850    /// <inheritdoc />
 1851    public async Task<List<BonusQuestion>> GetOpenBonusQuestionsAsync(string community)
 1852    {
 1853        try
 1854        {
 11855            var url = $"{community}/tippabgabe?bonus=true";
 11856            var response = await _httpClient.GetAsync(url);
 1857
 11858            if (!response.IsSuccessStatusCode)
 1859            {
 11860                _logger.LogError("Failed to fetch tippabgabe page for bonus questions. Status: {StatusCode}", response.S
 11861                return new List<BonusQuestion>();
 1862            }
 1863
 11864            var content = await response.Content.ReadAsStringAsync();
 11865            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1866
 11867            var bonusQuestions = new List<BonusQuestion>();
 1868
 1869            // Parse bonus questions from the tippabgabeFragen table
 11870            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 11871            if (bonusTable == null)
 1872            {
 11873                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 11874                return bonusQuestions;
 1875            }
 1876
 11877            var questionRows = bonusTable.QuerySelectorAll("tr");
 11878            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows", questionRows.Length);
 1879
 11880            foreach (var row in questionRows)
 1881            {
 11882                var cells = row.QuerySelectorAll("td");
 11883                if (cells.Length < 3) continue;
 1884
 1885                // Extract deadline and question text
 11886                var deadlineText = cells[0]?.TextContent?.Trim();
 11887                var questionText = cells[1]?.TextContent?.Trim();
 1888
 11889                if (string.IsNullOrEmpty(questionText)) continue;
 1890
 1891                // Parse deadline
 11892                var deadline = ParseMatchDateTime(deadlineText ?? "");
 1893
 1894                // Extract options from select elements
 11895                var tipCell = cells[2];
 11896                var selectElements = tipCell?.QuerySelectorAll("select");
 11897                var options = new List<BonusQuestionOption>();
 11898                string? formFieldName = null;
 11899                int maxSelections = 1; // Default to single selection
 1900
 11901                if (selectElements != null && selectElements.Length > 0)
 1902                {
 1903                    // The number of select elements indicates how many selections are allowed
 11904                    maxSelections = selectElements.Length;
 1905
 1906                    // Use the first select element to get the available options
 11907                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 11908                    formFieldName = firstSelect?.Name;
 1909
 11910                    var optionElements = firstSelect?.QuerySelectorAll("option");
 11911                    if (optionElements != null)
 1912                    {
 11913                        foreach (var option in optionElements.Cast<IHtmlOptionElement>())
 1914                        {
 11915                            if (option.Value != "-1" && !string.IsNullOrEmpty(option.Text))
 1916                            {
 11917                                options.Add(new BonusQuestionOption(option.Value, option.Text.Trim()));
 1918                            }
 1919                        }
 1920                    }
 1921                }
 1922
 11923                if (options.Any())
 1924                {
 11925                    bonusQuestions.Add(new BonusQuestion(
 11926                        Text: questionText,
 11927                        Deadline: deadline,
 11928                        Options: options,
 11929                        MaxSelections: maxSelections,
 11930                        FormFieldName: formFieldName
 11931                    ));
 1932                }
 1933            }
 1934
 11935            _logger.LogInformation("Successfully parsed {QuestionCount} bonus questions", bonusQuestions.Count);
 11936            return bonusQuestions;
 1937        }
 01938        catch (Exception ex)
 1939        {
 01940            _logger.LogError(ex, "Exception in GetOpenBonusQuestionsAsync");
 01941            return new List<BonusQuestion>();
 1942        }
 11943    }
 1944
 1945    /// <inheritdoc />
 1946    public async Task<Dictionary<string, BonusPrediction?>> GetPlacedBonusPredictionsAsync(string community)
 1947    {
 1948        try
 1949        {
 11950            var url = $"{community}/tippabgabe?bonus=true";
 11951            var response = await _httpClient.GetAsync(url);
 1952
 11953            if (!response.IsSuccessStatusCode)
 1954            {
 11955                _logger.LogError("Failed to fetch tippabgabe page for placed bonus predictions. Status: {StatusCode}", r
 11956                return new Dictionary<string, BonusPrediction?>();
 1957            }
 1958
 11959            var content = await response.Content.ReadAsStringAsync();
 11960            var document = await _browsingContext.OpenAsync(req => req.Content(content));
 1961
 11962            var placedPredictions = new Dictionary<string, BonusPrediction?>();
 1963
 1964            // Parse bonus questions from the tippabgabeFragen table
 11965            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 11966            if (bonusTable == null)
 1967            {
 11968                _logger.LogDebug("No bonus questions table found - this is normal if no bonus questions are available");
 11969                return placedPredictions;
 1970            }
 1971
 11972            var questionRows = bonusTable.QuerySelectorAll("tr");
 11973            _logger.LogDebug("Found {QuestionRowCount} potential bonus question rows for placed predictions", questionRo
 1974
 11975            foreach (var row in questionRows)
 1976            {
 11977                var cells = row.QuerySelectorAll("td");
 11978                if (cells.Length < 3) continue;
 1979
 1980                // Extract question text
 11981                var questionText = cells[1]?.TextContent?.Trim();
 11982                if (string.IsNullOrEmpty(questionText)) continue;
 1983
 1984                // Extract current selections from select elements
 11985                var tipCell = cells[2];
 11986                var selectElements = tipCell?.QuerySelectorAll("select");
 1987
 11988                if (selectElements != null && selectElements.Length > 0)
 1989                {
 1990                    // Extract form field name from the first select element
 11991                    var firstSelect = selectElements[0] as IHtmlSelectElement;
 11992                    var formFieldName = firstSelect?.Name;
 1993
 11994                    var selectedOptionIds = new List<string>();
 1995
 1996                    // Check each select element for its current selection
 11997                    foreach (var selectElement in selectElements.Cast<IHtmlSelectElement>())
 1998                    {
 11999                        var selectedOption = selectElement.SelectedOptions.FirstOrDefault();
 12000                        if (selectedOption != null && selectedOption.Value != "-1" && !string.IsNullOrEmpty(selectedOpti
 2001                        {
 12002                            selectedOptionIds.Add(selectedOption.Value);
 2003                        }
 2004                    }
 2005
 2006                    // Use form field name as key, fall back to question text
 12007                    var dictionaryKey = formFieldName ?? questionText;
 2008
 2009                    // Only create a prediction if there are actual selections
 12010                    if (selectedOptionIds.Any())
 2011                    {
 12012                        placedPredictions[dictionaryKey] = new BonusPrediction(selectedOptionIds);
 2013                    }
 2014                    else
 2015                    {
 12016                        placedPredictions[dictionaryKey] = null; // No prediction placed
 2017                    }
 2018                }
 2019            }
 2020
 12021            _logger.LogInformation("Successfully retrieved placed predictions for {QuestionCount} bonus questions", plac
 12022            return placedPredictions;
 2023        }
 02024        catch (Exception ex)
 2025        {
 02026            _logger.LogError(ex, "Exception in GetPlacedBonusPredictionsAsync");
 02027            return new Dictionary<string, BonusPrediction?>();
 2028        }
 12029    }
 2030
 2031    /// <inheritdoc />
 2032    public async Task<bool> PlaceBonusPredictionsAsync(string community, Dictionary<string, BonusPrediction> predictions
 2033    {
 2034        try
 2035        {
 12036            if (!predictions.Any())
 2037            {
 12038                _logger.LogInformation("No bonus predictions to place");
 12039                return true;
 2040            }
 2041
 12042            var url = $"{community}/tippabgabe?bonus=true";
 12043            var response = await _httpClient.GetAsync(url);
 2044
 12045            if (!response.IsSuccessStatusCode)
 2046            {
 12047                _logger.LogError("Failed to access betting page for bonus predictions. Status: {StatusCode}", response.S
 12048                return false;
 2049            }
 2050
 12051            var pageContent = await response.Content.ReadAsStringAsync();
 12052            var document = await _browsingContext.OpenAsync(req => req.Content(pageContent));
 2053
 2054            // Find the bet form
 12055            var betForm = document.QuerySelector("form") as IHtmlFormElement;
 12056            if (betForm == null)
 2057            {
 12058                _logger.LogWarning("Could not find betting form on the page");
 12059                return false;
 2060            }
 2061
 12062            var formData = new List<KeyValuePair<string, string>>();
 2063
 2064            // Copy hidden inputs from the original form
 12065            var hiddenInputs = betForm.QuerySelectorAll("input[type='hidden']");
 12066            foreach (var hiddenInput in hiddenInputs.Cast<IHtmlInputElement>())
 2067            {
 12068                if (!string.IsNullOrEmpty(hiddenInput.Name) && hiddenInput.Value != null)
 2069                {
 12070                    formData.Add(new KeyValuePair<string, string>(hiddenInput.Name, hiddenInput.Value));
 2071                }
 2072            }
 2073
 2074            // Copy existing match predictions to avoid overwriting them
 12075            var allInputs = betForm.QuerySelectorAll("input[type=text], input[type=number]").OfType<IHtmlInputElement>()
 12076            foreach (var input in allInputs)
 2077            {
 12078                if (!string.IsNullOrEmpty(input.Name) && !string.IsNullOrEmpty(input.Value))
 2079                {
 02080                    formData.Add(new KeyValuePair<string, string>(input.Name, input.Value));
 2081                }
 2082            }
 2083
 2084            // Add bonus predictions
 12085            var bonusTable = document.QuerySelector("#tippabgabeFragen tbody");
 12086            if (bonusTable != null)
 2087            {
 12088                var questionRows = bonusTable.QuerySelectorAll("tr");
 2089
 12090                foreach (var row in questionRows)
 2091                {
 12092                    var cells = row.QuerySelectorAll("td");
 12093                    if (cells.Length < 3) continue;
 2094
 12095                    var tipCell = cells[2];
 12096                    var selectElements = tipCell?.QuerySelectorAll("select");
 2097
 12098                    if (selectElements != null)
 2099                    {
 12100                        var selectArray = selectElements.Cast<IHtmlSelectElement>().ToArray();
 2101
 2102                        // Check if we have a prediction for this question based on form field name match
 12103                        var matchingPrediction = predictions.FirstOrDefault(p =>
 12104                            selectArray.Any(sel => sel.Name == p.Key) ||
 12105                            selectArray.Any(sel => sel.Name?.Contains(p.Key) == true));
 2106
 12107                        if (matchingPrediction.Value != null && matchingPrediction.Value.SelectedOptionIds.Any())
 2108                        {
 12109                            var selectedOptions = matchingPrediction.Value.SelectedOptionIds;
 2110
 2111                            // For multi-selection questions, we need to fill multiple select elements
 12112                            for (int i = 0; i < Math.Min(selectArray.Length, selectedOptions.Count); i++)
 2113                            {
 12114                                var selectElement = selectArray[i];
 12115                                var fieldName = selectElement.Name;
 12116                                if (string.IsNullOrEmpty(fieldName)) continue;
 2117
 12118                                var selectedOptionId = selectedOptions[i];
 2119
 2120                                // Check if this option exists in the select element
 12121                                var optionExists = selectElement.QuerySelectorAll("option")
 12122                                    .Cast<IHtmlOptionElement>()
 12123                                    .Any(opt => opt.Value == selectedOptionId);
 2124
 12125                                if (optionExists)
 2126                                {
 12127                                    formData.Add(new KeyValuePair<string, string>(fieldName, selectedOptionId));
 12128                                    _logger.LogDebug("Added bonus prediction for field {FieldName}: {OptionId} (selectio
 12129                                        fieldName, selectedOptionId, i + 1);
 2130                                }
 2131                                else
 2132                                {
 02133                                    _logger.LogWarning("Option {OptionId} not found for field {FieldName}", selectedOpti
 2134                                }
 2135                            }
 2136                        }
 2137                    }
 2138                }
 2139            }
 2140
 2141            // Find submit button
 12142            var submitButton = betForm.QuerySelector("input[type=submit], button[type=submit]") as IHtmlElement;
 12143            if (submitButton != null)
 2144            {
 12145                if (submitButton is IHtmlInputElement inputSubmit && !string.IsNullOrEmpty(inputSubmit.Name))
 2146                {
 12147                    formData.Add(new KeyValuePair<string, string>(inputSubmit.Name, inputSubmit.Value ?? "Submit"));
 2148                }
 12149                else if (submitButton is IHtmlButtonElement buttonSubmit && !string.IsNullOrEmpty(buttonSubmit.Name))
 2150                {
 12151                    formData.Add(new KeyValuePair<string, string>(buttonSubmit.Name, buttonSubmit.Value ?? "Submit"));
 2152                }
 2153            }
 2154            else
 2155            {
 2156                // Fallback to default submit button name
 02157                formData.Add(new KeyValuePair<string, string>("submitbutton", "Submit"));
 2158            }
 2159
 2160            // Submit form
 12161            var formActionUrl = string.IsNullOrEmpty(betForm.Action) ? url :
 12162                (betForm.Action.StartsWith("http") ? betForm.Action :
 12163                 betForm.Action.StartsWith("/") ? betForm.Action :
 12164                 $"{community}/{betForm.Action}");
 2165
 12166            var formContent = new FormUrlEncodedContent(formData);
 12167            var submitResponse = await _httpClient.PostAsync(formActionUrl, formContent);
 2168
 12169            if (submitResponse.IsSuccessStatusCode)
 2170            {
 12171                _logger.LogInformation("✓ Successfully submitted {PredictionCount} bonus predictions!", predictions.Coun
 12172                return true;
 2173            }
 2174            else
 2175            {
 12176                _logger.LogError("✗ Failed to submit bonus predictions. Status: {StatusCode}", submitResponse.StatusCode
 12177                return false;
 2178            }
 2179        }
 02180        catch (Exception ex)
 2181        {
 02182            _logger.LogError(ex, "Exception during bonus prediction placement");
 02183            return false;
 2184        }
 12185    }
 2186
 2187    /// <summary>
 2188    /// Expands match annotation abbreviations to their full text.
 2189    /// </summary>
 2190    /// <param name="annotation">The abbreviated annotation (e.g., "n.E.", "n.V.")</param>
 2191    /// <returns>The expanded annotation or null if empty</returns>
 2192    private static string? ExpandAnnotation(string? annotation)
 2193    {
 12194        if (string.IsNullOrWhiteSpace(annotation))
 02195            return null;
 2196
 12197        return annotation.Trim() switch
 12198        {
 12199            "n.E." => "nach Elfmeterschießen",
 12200            "n.V." => "nach Verlängerung",
 02201            _ => annotation.Trim() // Return as-is if not recognized
 12202        };
 2203    }
 2204
 2205    public void Dispose()
 2206    {
 02207        _httpClient?.Dispose();
 02208        _browsingContext?.Dispose();
 02209    }
 2210}