1use serde::Deserialize;
9use time::{Date, Month, OffsetDateTime, Time};
10
11use crate::backends::common::{day_bounds, f64_to_i16, parse_time};
12use crate::domain::{
13 Bracket, BracketRound, Calendar, Group, GroupStanding, Lineup, Match, MatchDetail, MatchStatus,
14 Score, Stage, StageWindow, Team,
15};
16use crate::error::Result;
17use crate::provider::ScoreProvider;
18use crate::transport::Http;
19
20const BASE_URL: &str = "https://api.football-data.org/v4";
21
22#[derive(Debug, Clone)]
24pub struct FootballDataProvider {
25 http: Http,
26 key: String,
27}
28
29impl FootballDataProvider {
30 #[must_use]
32 pub fn new(http: Http, key: String) -> Self {
33 Self { http, key }
34 }
35
36 fn headers(&self) -> [(&str, &str); 1] {
37 [("X-Auth-Token", self.key.as_str())]
38 }
39
40 async fn fetch<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
41 self.http
42 .get_json(&format!("{BASE_URL}{path}"), &self.headers())
43 .await
44 }
45}
46
47impl ScoreProvider for FootballDataProvider {
48 fn name(&self) -> &'static str {
49 "football-data.org"
50 }
51
52 async fn calendar(&self) -> Result<Calendar> {
53 Ok(static_calendar())
54 }
55
56 async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
57 let dto: FdMatches = self.fetch("/competitions/WC/matches").await?;
58 let calendar = static_calendar();
59 let mut matches = dto
60 .matches
61 .into_iter()
62 .map(|m| m.to_match(&calendar))
63 .collect::<Result<Vec<_>>>()?;
64 if let Some(day) = day {
65 let (start, end) = day_bounds(day);
66 matches.retain(|m| m.kickoff >= start && m.kickoff < end);
67 }
68 Ok(matches)
69 }
70
71 async fn standings(&self) -> Result<Vec<Group>> {
72 let dto: FdStandingsResponse = self.fetch("/competitions/WC/standings").await?;
73 Ok(dto
74 .standings
75 .into_iter()
76 .map(FdStanding::into_group)
77 .collect())
78 }
79
80 async fn bracket(&self) -> Result<Bracket> {
81 let matches = self.scoreboard(None).await?;
82 Ok(Bracket {
83 rounds: Stage::knockout_order()
84 .into_iter()
85 .map(|stage| BracketRound {
86 stage,
87 matches: matches
88 .iter()
89 .filter(|m| m.stage == stage)
90 .cloned()
91 .collect(),
92 })
93 .collect(),
94 })
95 }
96
97 async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
98 let dto: FdMatchEnvelope = self.fetch(&format!("/matches/{id}")).await?;
99 Ok(MatchDetail {
101 summary: dto.match_.to_match(&static_calendar())?,
102 events: Vec::new(),
103 lineups: dto.match_.lineups(),
104 stats: Vec::new(),
105 })
106 }
107}
108
109fn static_calendar() -> Calendar {
110 Calendar {
111 stages: vec![
112 window(
113 Stage::GroupStage,
114 "Group Stage",
115 (2026, Month::June, 11),
116 (2026, Month::June, 28),
117 ),
118 window(
119 Stage::RoundOf32,
120 "Round of 32",
121 (2026, Month::June, 28),
122 (2026, Month::July, 4),
123 ),
124 window(
125 Stage::RoundOf16,
126 "Round of 16",
127 (2026, Month::July, 4),
128 (2026, Month::July, 9),
129 ),
130 window(
131 Stage::QuarterFinal,
132 "Quarter-final",
133 (2026, Month::July, 9),
134 (2026, Month::July, 14),
135 ),
136 window(
137 Stage::SemiFinal,
138 "Semi-final",
139 (2026, Month::July, 14),
140 (2026, Month::July, 18),
141 ),
142 window(
143 Stage::ThirdPlace,
144 "Third place",
145 (2026, Month::July, 18),
146 (2026, Month::July, 19),
147 ),
148 window(
149 Stage::Final,
150 "Final",
151 (2026, Month::July, 19),
152 (2026, Month::August, 1),
153 ),
154 ],
155 }
156}
157
158fn window(
159 stage: Stage,
160 label: &str,
161 start: (i32, Month, u8),
162 end: (i32, Month, u8),
163) -> StageWindow {
164 StageWindow {
165 stage,
166 label: label.to_owned(),
167 start: Date::from_calendar_date(start.0, start.1, start.2)
168 .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
169 d.with_time(Time::MIDNIGHT).assume_utc()
170 }),
171 end: Date::from_calendar_date(end.0, end.1, end.2)
172 .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
173 d.with_time(Time::MIDNIGHT).assume_utc()
174 }),
175 }
176}
177
178#[derive(Debug, Deserialize)]
179struct FdMatches {
180 matches: Vec<FdMatch>,
181}
182#[derive(Debug, Deserialize)]
183struct FdMatchEnvelope {
184 #[serde(rename = "match")]
185 match_: FdMatch,
186}
187
188#[derive(Debug, Deserialize)]
189struct FdMatch {
190 id: i64,
191 #[serde(rename = "utcDate")]
192 utc_date: String,
193 status: String,
194 stage: Option<String>,
195 group: Option<String>,
196 #[serde(rename = "homeTeam")]
197 home_team: FdTeam,
198 #[serde(rename = "awayTeam")]
199 away_team: FdTeam,
200 score: FdScore,
201 #[serde(default)]
202 lineups: Vec<FdLineup>,
203}
204
205impl FdMatch {
206 fn to_match(&self, calendar: &Calendar) -> Result<Match> {
207 let kickoff = parse_time(&self.utc_date)?;
208 let status = fd_status(&self.status);
209 Ok(Match {
210 id: self.id.to_string(),
211 stage: crate::backends::common::stage_for_date(
212 calendar,
213 kickoff,
214 self.stage.as_deref().map_or(Stage::GroupStage, fd_stage),
215 ),
216 group: self
217 .group
218 .as_ref()
219 .map(|g| g.trim_start_matches("GROUP_").to_owned()),
220 home: self.home_team.to_domain(),
221 away: self.away_team.to_domain(),
222 score: self.score.to_domain(&status),
223 status,
224 kickoff,
225 venue: None,
226 })
227 }
228
229 fn lineups(&self) -> Vec<Lineup> {
230 self.lineups.iter().map(FdLineup::to_domain).collect()
231 }
232}
233
234fn fd_status(status: &str) -> MatchStatus {
235 match status {
236 "SCHEDULED" | "TIMED" => MatchStatus::Scheduled,
237 "IN_PLAY" => MatchStatus::Live {
238 minute: None,
239 detail: None,
240 },
241 "PAUSED" => MatchStatus::HalfTime,
242 "FINISHED" => MatchStatus::FullTime,
243 "POSTPONED" => MatchStatus::Postponed,
244 "CANCELLED" | "CANCELED" => MatchStatus::Canceled,
245 _ => MatchStatus::Unknown,
246 }
247}
248
249fn fd_stage(stage: &str) -> Stage {
250 match stage {
251 "LAST_32" => Stage::RoundOf32,
252 "LAST_16" => Stage::RoundOf16,
253 "QUARTER_FINALS" => Stage::QuarterFinal,
254 "SEMI_FINALS" => Stage::SemiFinal,
255 "THIRD_PLACE" => Stage::ThirdPlace,
256 "FINAL" => Stage::Final,
257 _ => Stage::GroupStage,
258 }
259}
260
261#[derive(Debug, Deserialize)]
262struct FdTeam {
263 id: Option<i64>,
264 name: Option<String>,
265 tla: Option<String>,
266 crest: Option<String>,
267}
268impl FdTeam {
269 fn to_domain(&self) -> Team {
270 let name = self.name.clone().unwrap_or_else(|| "TBD".to_owned());
271 Team {
272 id: self.id.map_or_else(String::new, |id| id.to_string()),
273 abbreviation: self
274 .tla
275 .clone()
276 .unwrap_or_else(|| name.chars().take(3).collect::<String>().to_uppercase()),
277 country_code: self.tla.clone(),
278 crest_url: self.crest.clone(),
279 name,
280 }
281 }
282}
283
284#[derive(Debug, Deserialize)]
285struct FdScore {
286 full_time: FdGoals,
287 regular_time: Option<FdGoals>,
288 penalties: Option<FdGoals>,
289}
290impl FdScore {
291 fn to_domain(&self, status: &MatchStatus) -> Option<Score> {
292 if matches!(status, MatchStatus::Scheduled) {
293 return None;
294 }
295 let goals = self.regular_time.as_ref().unwrap_or(&self.full_time);
296 Some(Score {
297 home: goals.home.unwrap_or_default().clamp(0, i64::from(u8::MAX)) as u8,
298 away: goals.away.unwrap_or_default().clamp(0, i64::from(u8::MAX)) as u8,
299 home_pens: self
300 .penalties
301 .as_ref()
302 .and_then(|p| p.home)
303 .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
304 away_pens: self
305 .penalties
306 .as_ref()
307 .and_then(|p| p.away)
308 .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
309 })
310 }
311}
312#[derive(Debug, Deserialize)]
313struct FdGoals {
314 home: Option<i64>,
315 away: Option<i64>,
316}
317
318#[derive(Debug, Deserialize)]
319struct FdStandingsResponse {
320 standings: Vec<FdStanding>,
321}
322#[derive(Debug, Deserialize)]
323struct FdStanding {
324 group: Option<String>,
325 table: Vec<FdStandingRow>,
326}
327impl FdStanding {
328 fn into_group(self) -> Group {
329 Group {
330 name: self
331 .group
332 .map(|g| g.trim_start_matches("GROUP_").to_owned())
333 .unwrap_or_default(),
334 standings: self
335 .table
336 .into_iter()
337 .map(FdStandingRow::into_domain)
338 .collect(),
339 }
340 }
341}
342#[derive(Debug, Deserialize)]
343struct FdStandingRow {
344 position: u8,
345 team: FdTeam,
346 played_games: u8,
347 won: u8,
348 draw: u8,
349 lost: u8,
350 goals_for: u16,
351 goals_against: u16,
352 goal_difference: i16,
353 points: u16,
354}
355impl FdStandingRow {
356 fn into_domain(self) -> GroupStanding {
357 GroupStanding {
358 team: self.team.to_domain(),
359 rank: self.position,
360 played: self.played_games,
361 won: self.won,
362 drawn: self.draw,
363 lost: self.lost,
364 goals_for: self.goals_for,
365 goals_against: self.goals_against,
366 goal_diff: f64_to_i16(Some(f64::from(self.goal_difference))),
367 points: self.points,
368 }
369 }
370}
371
372#[derive(Debug, Deserialize)]
373struct FdLineup {
374 team: FdTeam,
375 formation: Option<String>,
376 #[serde(default)]
377 start_xi: Vec<FdPlayerSlot>,
378 #[serde(default)]
379 substitutes: Vec<FdPlayerSlot>,
380}
381impl FdLineup {
382 fn to_domain(&self) -> Lineup {
383 Lineup {
384 team_id: self.team.id.map_or_else(String::new, |id| id.to_string()),
385 formation: self.formation.clone(),
386 starters: self.start_xi.iter().map(FdPlayerSlot::to_domain).collect(),
387 substitutes: self
388 .substitutes
389 .iter()
390 .map(FdPlayerSlot::to_domain)
391 .collect(),
392 }
393 }
394}
395#[derive(Debug, Deserialize)]
396struct FdPlayerSlot {
397 name: Option<String>,
398 shirt_number: Option<u8>,
399 position: Option<String>,
400}
401impl FdPlayerSlot {
402 fn to_domain(&self) -> crate::domain::Player {
403 crate::domain::Player {
404 name: self.name.clone().unwrap_or_default(),
405 number: self.shirt_number,
406 position: self.position.clone(),
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::error::DataError;
415
416 #[test]
417 fn maps_football_data_matches() -> Result<()> {
418 let dto: FdMatches = serde_json::from_str(include_str!(
419 "../../tests/fixtures/football_data_matches.json"
420 ))
421 .map_err(|e| DataError::Decode(e.to_string()))?;
422 let matches = dto
423 .matches
424 .iter()
425 .map(|m| m.to_match(&static_calendar()))
426 .collect::<Result<Vec<_>>>()?;
427 assert_eq!(matches[0].home.name, "Canada");
428 assert_eq!(matches[0].status, MatchStatus::Scheduled);
429 assert_eq!(matches[1].stage, Stage::RoundOf32);
430 Ok(())
431 }
432
433 #[test]
434 fn maps_football_data_standings() -> Result<()> {
435 let dto: FdStandingsResponse = serde_json::from_str(include_str!(
436 "../../tests/fixtures/football_data_standings.json"
437 ))
438 .map_err(|e| DataError::Decode(e.to_string()))?;
439 let groups: Vec<_> = dto
440 .standings
441 .into_iter()
442 .map(FdStanding::into_group)
443 .collect();
444 assert_eq!(groups[0].standings[0].team.name, "Mexico");
445 assert_eq!(groups[0].standings[0].points, 3);
446 Ok(())
447 }
448}