1use serde::Deserialize;
8use time::{Date, Month, OffsetDateTime, Time};
9
10use crate::backends::common::{
11 api_status, day_bounds, f64_to_i16, f64_to_u16, group_from_text, parse_time,
12};
13use crate::domain::{
14 Bracket, BracketRound, Calendar, Group, GroupStanding, Lineup, Match, MatchDetail, MatchEvent,
15 MatchEventKind, MatchStatus, Player, Score, Stage, StageWindow, Team, TeamStat,
16};
17use crate::error::{DataError, Result};
18use crate::provider::ScoreProvider;
19use crate::transport::Http;
20
21const BASE_URL: &str = "https://v3.football.api-sports.io";
22const WORLD_CUP_LEAGUE: u16 = 1;
23const WORLD_CUP_SEASON: u16 = 2026;
24
25#[derive(Debug, Clone)]
27pub struct ApiFootballProvider {
28 http: Http,
29 key: String,
30}
31
32impl ApiFootballProvider {
33 #[must_use]
35 pub fn new(http: Http, key: String) -> Self {
36 Self { http, key }
37 }
38
39 fn headers(&self) -> [(&str, &str); 1] {
40 [("x-apisports-key", self.key.as_str())]
41 }
42
43 async fn fetch<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
44 self.http
45 .get_json(&format!("{BASE_URL}{path}"), &self.headers())
46 .await
47 }
48}
49
50impl ScoreProvider for ApiFootballProvider {
51 fn name(&self) -> &'static str {
52 "API-Football"
53 }
54
55 async fn calendar(&self) -> Result<Calendar> {
56 Ok(static_calendar())
57 }
58
59 async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
60 let dto: ApiResponse<ApiFixture> = self
62 .fetch(&format!(
63 "/fixtures?league={WORLD_CUP_LEAGUE}&season={WORLD_CUP_SEASON}"
64 ))
65 .await?;
66 let calendar = static_calendar();
67 let mut matches = dto
68 .response
69 .into_iter()
70 .map(|fixture| fixture.into_match(&calendar))
71 .collect::<Result<Vec<_>>>()?;
72 if let Some(day) = day {
73 let (start, end) = day_bounds(day);
74 matches.retain(|m| m.kickoff >= start && m.kickoff < end);
75 }
76 Ok(matches)
77 }
78
79 async fn standings(&self) -> Result<Vec<Group>> {
80 let dto: ApiResponse<ApiStandingLeagueEnvelope> = self
81 .fetch(&format!(
82 "/standings?league={WORLD_CUP_LEAGUE}&season={WORLD_CUP_SEASON}"
83 ))
84 .await?;
85 Ok(dto
86 .response
87 .into_iter()
88 .flat_map(|r| {
89 r.league.standings.into_iter().map(|table| Group {
90 name: table
91 .first()
92 .and_then(|row| group_from_text(row.group.as_deref()))
93 .unwrap_or_default(),
94 standings: table.into_iter().map(ApiStanding::into_domain).collect(),
95 })
96 })
97 .collect())
98 }
99
100 async fn bracket(&self) -> Result<Bracket> {
101 let matches = self.scoreboard(None).await?;
102 Ok(Bracket {
103 rounds: Stage::knockout_order()
104 .into_iter()
105 .map(|stage| BracketRound {
106 stage,
107 matches: matches
108 .iter()
109 .filter(|m| m.stage == stage)
110 .cloned()
111 .collect(),
112 })
113 .collect(),
114 })
115 }
116
117 async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
118 let fixtures: ApiResponse<ApiFixture> = self.fetch(&format!("/fixtures?id={id}")).await?;
119 let summary = fixtures
120 .response
121 .into_iter()
122 .next()
123 .ok_or_else(|| DataError::Decode(format!("API-Football fixture {id} not found")))?
124 .into_match(&static_calendar())?;
125 let events: ApiResponse<ApiEvent> = self
126 .fetch(&format!("/fixtures/events?fixture={id}"))
127 .await?;
128 let lineups: ApiResponse<ApiLineup> = self
129 .fetch(&format!("/fixtures/lineups?fixture={id}"))
130 .await?;
131 let stats: ApiResponse<ApiStatisticTeam> = self
132 .fetch(&format!("/fixtures/statistics?fixture={id}"))
133 .await?;
134 Ok(MatchDetail {
135 summary,
136 events: events
137 .response
138 .into_iter()
139 .map(ApiEvent::into_domain)
140 .collect(),
141 lineups: lineups
142 .response
143 .into_iter()
144 .map(ApiLineup::into_domain)
145 .collect(),
146 stats: api_team_stats(&stats.response),
147 })
148 }
149}
150
151fn static_calendar() -> Calendar {
152 Calendar {
153 stages: vec![
154 window(
155 Stage::GroupStage,
156 "Group Stage",
157 (2026, Month::June, 11),
158 (2026, Month::June, 28),
159 ),
160 window(
161 Stage::RoundOf32,
162 "Round of 32",
163 (2026, Month::June, 28),
164 (2026, Month::July, 4),
165 ),
166 window(
167 Stage::RoundOf16,
168 "Round of 16",
169 (2026, Month::July, 4),
170 (2026, Month::July, 9),
171 ),
172 window(
173 Stage::QuarterFinal,
174 "Quarter-final",
175 (2026, Month::July, 9),
176 (2026, Month::July, 14),
177 ),
178 window(
179 Stage::SemiFinal,
180 "Semi-final",
181 (2026, Month::July, 14),
182 (2026, Month::July, 18),
183 ),
184 window(
185 Stage::ThirdPlace,
186 "Third place",
187 (2026, Month::July, 18),
188 (2026, Month::July, 19),
189 ),
190 window(
191 Stage::Final,
192 "Final",
193 (2026, Month::July, 19),
194 (2026, Month::August, 1),
195 ),
196 ],
197 }
198}
199
200fn window(
201 stage: Stage,
202 label: &str,
203 start: (i32, Month, u8),
204 end: (i32, Month, u8),
205) -> StageWindow {
206 StageWindow {
207 stage,
208 label: label.to_owned(),
209 start: Date::from_calendar_date(start.0, start.1, start.2)
210 .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
211 d.with_time(Time::MIDNIGHT).assume_utc()
212 }),
213 end: Date::from_calendar_date(end.0, end.1, end.2)
214 .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
215 d.with_time(Time::MIDNIGHT).assume_utc()
216 }),
217 }
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(bound(deserialize = "T: Deserialize<'de>"))]
222struct ApiResponse<T> {
223 #[serde(default)]
224 response: Vec<T>,
225}
226
227#[derive(Debug, Deserialize)]
228struct ApiFixture {
229 fixture: FixtureInfo,
230 league: ApiLeague,
231 teams: ApiTeams,
232 goals: ApiGoals,
233 score: ApiScore,
234}
235
236impl ApiFixture {
237 fn into_match(self, calendar: &Calendar) -> Result<Match> {
238 let kickoff = parse_time(&self.fixture.date)?;
239 let status = api_status(
240 &self.fixture.status.short,
241 self.fixture.status.elapsed,
242 self.fixture.status.long.clone(),
243 );
244 let score = if matches!(status, MatchStatus::Scheduled) {
245 None
246 } else {
247 Some(Score {
248 home: self
249 .goals
250 .home
251 .unwrap_or_default()
252 .clamp(0, i64::from(u8::MAX)) as u8,
253 away: self
254 .goals
255 .away
256 .unwrap_or_default()
257 .clamp(0, i64::from(u8::MAX)) as u8,
258 home_pens: self
259 .score
260 .penalty
261 .home
262 .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
263 away_pens: self
264 .score
265 .penalty
266 .away
267 .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
268 })
269 };
270 Ok(Match {
271 id: self.fixture.id.to_string(),
272 stage: crate::backends::common::stage_for_date(
273 calendar,
274 kickoff,
275 stage_from_round(self.league.round.as_deref()),
276 ),
277 group: group_from_text(self.league.round.as_deref()),
278 home: self.teams.home.to_domain(),
279 away: self.teams.away.to_domain(),
280 score,
281 status,
282 kickoff,
283 venue: self.fixture.venue.and_then(|v| v.name),
284 })
285 }
286}
287
288fn stage_from_round(round: Option<&str>) -> Stage {
289 let text = round.unwrap_or_default().to_ascii_lowercase();
290 if text.contains("round of 32") {
291 Stage::RoundOf32
292 } else if text.contains("round of 16") {
293 Stage::RoundOf16
294 } else if text.contains("quarter") {
295 Stage::QuarterFinal
296 } else if text.contains("semi") {
297 Stage::SemiFinal
298 } else if text.contains("third") {
299 Stage::ThirdPlace
300 } else if text.contains("final") {
301 Stage::Final
302 } else {
303 Stage::GroupStage
304 }
305}
306
307#[derive(Debug, Deserialize)]
308struct FixtureInfo {
309 id: i64,
310 date: String,
311 status: ApiStatus,
312 venue: Option<ApiVenue>,
313}
314#[derive(Debug, Deserialize)]
315struct ApiStatus {
316 long: Option<String>,
317 short: String,
318 elapsed: Option<u16>,
319}
320#[derive(Debug, Deserialize)]
321struct ApiVenue {
322 name: Option<String>,
323}
324#[derive(Debug, Deserialize)]
325struct ApiLeague {
326 round: Option<String>,
327}
328#[derive(Debug, Deserialize)]
329struct ApiTeams {
330 home: ApiTeam,
331 away: ApiTeam,
332}
333#[derive(Debug, Deserialize)]
334struct ApiTeam {
335 id: Option<i64>,
336 name: Option<String>,
337 code: Option<String>,
338 logo: Option<String>,
339}
340impl ApiTeam {
341 fn to_domain(&self) -> Team {
342 let name = self.name.clone().unwrap_or_else(|| "TBD".to_owned());
343 Team {
344 id: self.id.map_or_else(String::new, |id| id.to_string()),
345 abbreviation: self
346 .code
347 .clone()
348 .unwrap_or_else(|| name.chars().take(3).collect::<String>().to_uppercase()),
349 country_code: self.code.clone(),
350 crest_url: self.logo.clone(),
351 name,
352 }
353 }
354}
355#[derive(Debug, Deserialize)]
356struct ApiGoals {
357 home: Option<i64>,
358 away: Option<i64>,
359}
360#[derive(Debug, Deserialize)]
361struct ApiScore {
362 penalty: ApiGoals,
363}
364
365#[derive(Debug, Deserialize)]
366struct ApiStandingLeagueEnvelope {
367 league: ApiStandingLeague,
368}
369#[derive(Debug, Deserialize)]
370struct ApiStandingLeague {
371 standings: Vec<Vec<ApiStanding>>,
372}
373#[derive(Debug, Deserialize)]
374struct ApiStanding {
375 rank: u8,
376 team: ApiTeam,
377 group: Option<String>,
378 all: ApiStandingAll,
379 goals_diff: i16,
380 points: Option<u16>,
381}
382impl ApiStanding {
383 fn into_domain(self) -> GroupStanding {
384 GroupStanding {
385 team: self.team.to_domain(),
386 rank: self.rank,
387 played: self.all.played,
388 won: self.all.win,
389 drawn: self.all.draw,
390 lost: self.all.lose,
391 goals_for: f64_to_u16(Some(f64::from(self.all.goals.for_))),
392 goals_against: f64_to_u16(Some(f64::from(self.all.goals.against))),
393 goal_diff: f64_to_i16(Some(f64::from(self.goals_diff))),
394 points: self.points.unwrap_or_default(),
395 }
396 }
397}
398#[derive(Debug, Deserialize)]
399struct ApiStandingAll {
400 played: u8,
401 win: u8,
402 draw: u8,
403 lose: u8,
404 goals: ApiStandingGoals,
405}
406#[derive(Debug, Deserialize)]
407struct ApiStandingGoals {
408 #[serde(rename = "for")]
409 for_: u16,
410 against: u16,
411}
412
413#[derive(Debug, Deserialize)]
414struct ApiEvent {
415 time: ApiEventTime,
416 team: ApiTeam,
417 player: Option<ApiPerson>,
418 assist: Option<ApiPerson>,
419 #[serde(rename = "type")]
420 type_: String,
421 detail: Option<String>,
422 comments: Option<String>,
423}
424impl ApiEvent {
425 fn into_domain(self) -> MatchEvent {
426 MatchEvent {
427 minute: self.time.elapsed,
428 stoppage: self.time.extra,
429 kind: api_event_kind(&self.type_, self.detail.as_deref()),
430 team_id: self.team.id.map(|id| id.to_string()),
431 player: self.player.and_then(|p| p.name),
432 detail: self
433 .comments
434 .or_else(|| self.assist.and_then(|p| p.name))
435 .or(self.detail),
436 }
437 }
438}
439#[derive(Debug, Deserialize)]
440struct ApiEventTime {
441 elapsed: Option<u16>,
442 extra: Option<u16>,
443}
444#[derive(Debug, Deserialize)]
445struct ApiPerson {
446 name: Option<String>,
447}
448fn api_event_kind(type_: &str, detail: Option<&str>) -> MatchEventKind {
449 let text = format!("{} {}", type_, detail.unwrap_or_default()).to_ascii_lowercase();
450 if text.contains("own") {
451 MatchEventKind::OwnGoal
452 } else if text.contains("missed") {
453 MatchEventKind::PenaltyMiss
454 } else if text.contains("penalty") {
455 MatchEventKind::PenaltyGoal
456 } else if text.contains("goal") {
457 MatchEventKind::Goal
458 } else if text.contains("yellow") {
459 MatchEventKind::YellowCard
460 } else if text.contains("red") {
461 MatchEventKind::RedCard
462 } else if text.contains("sub") {
463 MatchEventKind::Substitution
464 } else if text.contains("var") {
465 MatchEventKind::Var
466 } else {
467 MatchEventKind::Other
468 }
469}
470
471#[derive(Debug, Deserialize)]
472struct ApiLineup {
473 team: ApiTeam,
474 formation: Option<String>,
475 #[serde(default)]
476 start_xi: Vec<ApiLineupSlot>,
477 #[serde(default)]
478 substitutes: Vec<ApiLineupSlot>,
479}
480impl ApiLineup {
481 fn into_domain(self) -> Lineup {
482 Lineup {
483 team_id: self.team.id.map_or_else(String::new, |id| id.to_string()),
484 formation: self.formation,
485 starters: self
486 .start_xi
487 .into_iter()
488 .map(ApiLineupSlot::into_player)
489 .collect(),
490 substitutes: self
491 .substitutes
492 .into_iter()
493 .map(ApiLineupSlot::into_player)
494 .collect(),
495 }
496 }
497}
498#[derive(Debug, Deserialize)]
499struct ApiLineupSlot {
500 player: ApiLineupPlayer,
501}
502impl ApiLineupSlot {
503 fn into_player(self) -> Player {
504 Player {
505 name: self.player.name.unwrap_or_default(),
506 number: self
507 .player
508 .number
509 .map(|n| n.clamp(0, i64::from(u8::MAX)) as u8),
510 position: self.player.pos,
511 }
512 }
513}
514#[derive(Debug, Deserialize)]
515struct ApiLineupPlayer {
516 name: Option<String>,
517 number: Option<i64>,
518 pos: Option<String>,
519}
520
521#[derive(Debug, Deserialize)]
522struct ApiStatisticTeam {
523 #[serde(default)]
524 statistics: Vec<ApiStatistic>,
525}
526#[derive(Debug, Deserialize)]
527struct ApiStatistic {
528 #[serde(rename = "type")]
529 type_: String,
530 value: Option<serde_json::Value>,
531}
532fn api_team_stats(teams: &[ApiStatisticTeam]) -> Vec<TeamStat> {
533 if teams.len() < 2 {
534 return Vec::new();
535 }
536 teams[0]
537 .statistics
538 .iter()
539 .zip(&teams[1].statistics)
540 .map(|(home, away)| TeamStat {
541 label: home.type_.clone(),
542 home: stat_value(home.value.as_ref()),
543 away: stat_value(away.value.as_ref()),
544 })
545 .collect()
546}
547fn stat_value(value: Option<&serde_json::Value>) -> String {
548 value.map_or_else(String::new, |v| {
549 v.as_str().map_or_else(|| v.to_string(), ToOwned::to_owned)
550 })
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn maps_api_football_fixture() -> Result<()> {
559 let dto: ApiResponse<ApiFixture> = serde_json::from_str(include_str!(
560 "../../tests/fixtures/apifootball_scoreboard.json"
561 ))
562 .map_err(|e| DataError::Decode(e.to_string()))?;
563 let matches = dto
564 .response
565 .into_iter()
566 .map(|f| f.into_match(&static_calendar()))
567 .collect::<Result<Vec<_>>>()?;
568 assert_eq!(matches[0].home.name, "Canada");
569 assert_eq!(matches[0].status, MatchStatus::Scheduled);
570 assert_eq!(matches[1].stage, Stage::RoundOf32);
571 Ok(())
572 }
573
574 #[test]
575 fn maps_api_football_events() -> Result<()> {
576 let dto: ApiResponse<ApiEvent> =
577 serde_json::from_str(include_str!("../../tests/fixtures/apifootball_events.json"))
578 .map_err(|e| DataError::Decode(e.to_string()))?;
579 let event = dto
580 .response
581 .into_iter()
582 .next()
583 .ok_or_else(|| DataError::Decode("missing event".to_owned()))?
584 .into_domain();
585 assert_eq!(event.kind, MatchEventKind::PenaltyGoal);
586 assert_eq!(event.player.as_deref(), Some("Alphonso Davies"));
587 Ok(())
588 }
589}