using System.Collections.Concurrent; using FozFc.Api.Configuration; using FozFc.Api.Infrastructure.Zerozero; using Microsoft.Extensions.Caching.Memory; namespace FozFc.Api.Application.Zerozero; public sealed class ZerozeroFeedService : IZerozeroFeedService { private static readonly TimeSpan MatchesTtl = TimeSpan.FromSeconds(86_400); private static readonly TimeSpan MatchesLiveTtl = TimeSpan.FromSeconds(60); private static readonly TimeSpan NewsTtl = TimeSpan.FromSeconds(86_400); private static readonly TimeSpan StandingsTtl = TimeSpan.FromSeconds(86_400); private const int NewsDetailsConcurrency = 4; private const int NewsDetailsMax = 15; private static readonly string[] ImageKeys = { "image", "logo", "homeLogo", "awayLogo" }; private readonly ZerozeroHttpClient _http; private readonly IMemoryCache _cache; private readonly AppOptions _options; private readonly ILogger _logger; public ZerozeroFeedService(ZerozeroHttpClient http, IMemoryCache cache, AppOptions options, ILogger logger) { _http = http; _cache = cache; _options = options; _logger = logger; } private string Origin => _options.PublicBaseUrl; // ---------------------------------------------------------------- matches public async Task> GetMatchesAsync(CancellationToken ct = default) { if (_cache.TryGetValue("zz:matches", out Dictionary? cached) && cached != null) return cached; var html = await _http.FetchHtmlAsync(ZerozeroSite.TeamGamesPath, ct: ct); var matches = ZerozeroParsers.ParseMatches(html); matches.ForEach(RewriteImages); var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); var hasLiveOrToday = matches.Any(m => (m.GetValueOrDefault("status")?.ToString() == "live") || (m.GetValueOrDefault("date")?.ToString() == today)); var payload = new Dictionary { ["matches"] = matches }; _cache.Set("zz:matches", payload, hasLiveOrToday ? MatchesLiveTtl : MatchesTtl); return payload; } // ---------------------------------------------------------------- news public async Task> GetNewsAsync(string? slug, CancellationToken ct = default) { var cacheKey = string.IsNullOrEmpty(slug) ? "zz:news" : $"zz:news:{slug}"; if (_cache.TryGetValue(cacheKey, out Dictionary? cached) && cached != null) return cached; var html = await _http.FetchHtmlAsync(ZerozeroSite.TeamNewsPath, ct: ct); var baseNews = ZerozeroParsers.ParseNewsList(html); // Fetch article detail for the target slug, or for the top items when listing. var toDetail = string.IsNullOrEmpty(slug) ? baseNews.Take(NewsDetailsMax).ToList() : baseNews.Where(n => n.GetValueOrDefault("slug")?.ToString() == slug).ToList(); var detailSet = new HashSet>(toDetail); var detailed = new ConcurrentDictionary?>(); await Parallel.ForEachAsync( baseNews.Select((item, index) => (item, index)), new ParallelOptions { MaxDegreeOfParallelism = NewsDetailsConcurrency, CancellationToken = ct }, async (pair, token) => { var (item, index) = pair; var externalUrl = item.GetValueOrDefault("externalUrl")?.ToString(); if (string.IsNullOrEmpty(externalUrl) || !detailSet.Contains(item)) { detailed[index] = item; return; } try { var articlePath = externalUrl.Replace(ZerozeroSite.Origin, ""); var articleHtml = await _http.FetchHtmlAsync(articlePath, $"{ZerozeroSite.Origin}{ZerozeroSite.TeamNewsPath}", token); detailed[index] = ZerozeroParsers.ParseNewsArticle(articleHtml, item); } catch (Exception ex) { _logger.LogWarning("[zerozero] news detail failed for {Url}: {Message}", externalUrl, ex.Message); detailed[index] = item; } }); var news = Enumerable.Range(0, baseNews.Count) .Select(i => detailed.GetValueOrDefault(i)) .Where(item => item != null) .Select(item => item!) .OrderByDescending(item => item.GetValueOrDefault("publishedAt")?.ToString(), StringComparer.Ordinal) .ToList(); news.ForEach(RewriteImages); var payload = new Dictionary { ["news"] = news }; _cache.Set(cacheKey, payload, NewsTtl); return payload; } // ---------------------------------------------------------------- standings public Task> GetStandingsLigaAsync(CancellationToken ct = default) => StandingsAsync("zz:standings-liga", ZerozeroSite.LigaProPath, ct); public Task> GetStandingsTacaAsync(CancellationToken ct = default) => StandingsAsync("zz:standings-taca", ZerozeroSite.TacaPath, ct); private async Task> StandingsAsync(string cacheKey, string path, CancellationToken ct) { if (_cache.TryGetValue(cacheKey, out Dictionary? cached) && cached != null) return cached; var html = await _http.FetchHtmlAsync(path, ct: ct); var standings = ZerozeroParsers.ParseStandings(html); standings.ForEach(RewriteImages); var payload = new Dictionary { ["standings"] = standings }; _cache.Set(cacheKey, payload, StandingsTtl); return payload; } // ---------------------------------------------------------------- cup progress public async Task> GetCupProgressAsync(CancellationToken ct = default) { if (_cache.TryGetValue("zz:cup-progress", out Dictionary? cached) && cached != null) return cached; var html = await _http.FetchHtmlAsync(ZerozeroSite.TeamGamesPath, ct: ct); var all = ZerozeroParsers.ParseMatches(html); var cup = all.Where(m => { var comp = (m.GetValueOrDefault("competition")?.ToString() ?? "").ToLowerInvariant(); return comp.Contains("taça") || comp.Contains("taca") || System.Text.RegularExpressions.Regex.IsMatch(comp, @"\bta.a\b"); }).ToList(); cup.ForEach(RewriteImages); var payload = new Dictionary { ["matches"] = cup }; _cache.Set("zz:cup-progress", payload, StandingsTtl); return payload; } // ---------------------------------------------------------------- helpers private void RewriteImages(Dictionary item) { foreach (var key in ImageKeys) { if (item.TryGetValue(key, out var value) && value is string s && !string.IsNullOrEmpty(s)) item[key] = ZerozeroImages.RewriteImageUrl(s, Origin); } } }