stat_summoner/module/loop_module/
utils.rs

1use chrono::Utc;
2use futures::StreamExt;
3use mongodb::{bson::doc, Collection};
4use poise::serenity_prelude::{self as serenity, CreateEmbed, CreateMessage, Http};
5use regex::Regex;
6use select::document::Document;
7use select::predicate::{Class, Name};
8use serde_json::Value;
9use std::{collections::HashMap, sync::Arc};
10
11use crate::{
12    models::{
13        data::{CoreBuildData, EmojiId, RunesData, SummonerFollowedData},
14        error::Error,
15    },
16    riot_api::{get_matchs_id, get_matchs_info},
17    utils::*,
18};
19
20/// ⚙️ **Function**: Extracts relevant match details for a given summoner from the match information.
21///
22/// This function retrieves detailed information about a match, focusing on the summoner specified by their `summoner_id`.
23/// It validates the game mode, identifies the summoner's performance, and compares their stats with the enemy team in each role (TOP, JUNGLE, MIDDLE, BOTTOM, UTILITY).
24///
25/// # Parameters:
26/// - `match_info`: A reference to a `Value` (from the `serde_json` crate) containing the entire match data fetched from the Riot API.
27/// - `summoner_id`: A string slice representing the summoner's ID, used to locate their stats in the match data.
28///
29/// # Returns:
30/// - `Option<Value>`: Returns a JSON object containing the match result (Victory or Defeat) and detailed role-based stats comparisons, or `None` if the game mode is invalid or the data is not available.
31///
32/// # Example:
33/// This function is typically used to extract and format match details for reporting to a Discord channel:
34///
35/// ```rust
36/// let match_details = get_match_details(&match_info, summoner_id);
37/// if let Some(details) = match_details {
38///     // Process match details for further use
39/// }
40/// ```
41///
42/// # Notes:
43/// - The function first checks if the game mode is valid using `is_valid_game_mode`. If the game mode is invalid, the function returns `None`.
44/// - It then searches for the summoner in the participants list and identifies their team and match result (Victory or Defeat).
45/// - The function separates the participants into two teams (the summoner's team and the enemy team) and compares stats for each role.
46/// - It generates JSON-formatted role matchups comparing stats between the summoner's team and their opponents for each role.
47pub fn get_match_details(match_info: &Value, puuid: &str) -> Option<Value> {
48    let queue_id = match_info["info"]["queueId"].as_i64().unwrap_or(-1);
49    let (game_duration_minutes, game_duration_secondes) =
50        seconds_to_time(match_info["info"]["gameDuration"].as_u64().unwrap_or(0));
51    let game_duration_string = format!("{}:{}", game_duration_minutes, game_duration_secondes);
52    // utilise QUEUE_ID_MAP qui est une constante dans models/constants.rs qui contient une liste de game modes faisant correspondre id -> game mode en str
53    let game_mode = get_game_mode(queue_id);
54
55    let participants = match_info["info"]["participants"].as_array()?;
56    let participant = participants
57        .iter()
58        .find(|p| p["puuid"].as_str().unwrap_or("") == puuid)?;
59
60    let team_id = participant["teamId"].as_i64().unwrap_or(0);
61    let win = participant["win"].as_bool().unwrap_or(false);
62    let game_result = if win { "Victory" } else { "Defeat" };
63
64    let mut team_participants: HashMap<String, &Value> = HashMap::new();
65    let mut enemy_participants: HashMap<String, &Value> = HashMap::new();
66
67    for p in participants {
68        let position = p["teamPosition"].as_str().unwrap_or("UNKNOWN").to_string();
69        let p_team_id = p["teamId"].as_i64().unwrap_or(0);
70        if p_team_id == team_id {
71            team_participants.insert(position.clone(), p);
72        } else {
73            enemy_participants.insert(position.clone(), p);
74        }
75    }
76
77    let roles = vec!["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"];
78
79    let mut matchups = Vec::new();
80
81    for role in roles {
82        if let (Some(team_p), Some(enemy_p)) =
83            (team_participants.get(role), enemy_participants.get(role))
84        {
85            let team_stats = extract_participant_stats(team_p);
86            let enemy_stats = extract_participant_stats(enemy_p);
87
88            let matchup = serde_json::json!({
89                "role": role,
90                "team": team_stats,
91                "enemy": enemy_stats
92            });
93
94            matchups.push(matchup);
95        }
96    }
97
98    Some(serde_json::json!({
99        "gameMode": game_mode,
100        "gameResult": game_result,
101        "gameDuration": game_duration_string,
102        "matchups": matchups
103    }))
104}
105
106/// ⚙️ **Function**: Creates a detailed embed for a player's match performance in Discord.
107///
108/// This asynchronous function generates a `CreateEmbed` object that includes detailed statistics
109/// of a player's match, such as the game mode, result, duration, and a role-by-role comparison
110/// of the player's team versus the enemy team. The embed is enriched with emojis and formatted
111/// data to make it visually appealing for Discord.
112///
113/// # Parameters:
114/// - `info_json`: A reference to a `Value` (from the `serde_json` crate) containing the match data fetched from the Riot API.
115/// - `player_name`: A string slice representing the player's name, used for the embed's title.
116/// - `collection_emoji`: A MongoDB `Collection` containing emoji mappings, which are used to enhance the embed with role and champion-specific emojis.
117///
118/// # Returns:
119/// - `CreateEmbed`: Returns a `CreateEmbed` object containing the formatted match data, including role-based comparisons and game metadata, ready to be sent to a Discord channel.
120///
121/// # Example:
122/// This function is typically used to send detailed match information to a Discord channel:
123///
124/// ```rust
125/// let embed = create_embed_loop(&info_json, "PlayerName", collection_emoji).await;
126/// // Send the embed to a Discord channel using your bot's message-sending logic
127/// ```
128///
129/// # Notes:
130/// - The function begins by extracting key game metadata (game mode, result, and duration) from `info_json`.
131/// - Based on the match result, it selects appropriate emojis and colors for the embed.
132/// - The function then constructs the title and proceeds to iterate over the available role-based matchups, comparing the stats of the player's team with the enemy team for each role (TOP, JUNGLE, MIDDLE, BOTTOM, UTILITY).
133/// - Role and champion names are replaced by their corresponding emojis from the `collection_emoji`, retrieved using the `get_emoji` function.
134/// - The function formats team and enemy stats (kills, deaths, assists, CS, gold, vision score) for each role and adds them as fields in the embed.
135/// - It returns a fully constructed `CreateEmbed` ready to be sent in a Discord message.
136pub async fn create_embed_loop(
137    info_json: &Value,
138    player_name: &str,
139    collection_emoji: Collection<EmojiId>,
140) -> CreateEmbed {
141    let game_mode = info_json["gameMode"].as_str().unwrap_or("Unknown");
142    let game_result = info_json["gameResult"].as_str().unwrap_or("Unknown");
143    let game_duration = info_json["gameDuration"].as_str().unwrap_or("00:00");
144    let game_result_emoji = if game_result == "Victory" {
145        "🏆"
146    } else {
147        "❌"
148    };
149    let game_result_thumbnail = if game_result == "Victory" {
150        "https://i.postimg.cc/CxwjnWVk/pngegg.png"
151    } else {
152        "https://i.postimg.cc/XJBF0WwS/pngwing-com.png"
153    };
154    let color: i32 = if game_result == "Victory" {
155        0x00ff00
156    } else {
157        0xff0000
158    };
159
160    // Construct the embed title
161    let title = format!(
162        "**{}** - **{}: {} {} - {} **",
163        player_name, game_mode, game_result, game_result_emoji, game_duration
164    );
165
166    let roles_order = ["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"];
167    let mut matchups_by_role = std::collections::HashMap::new();
168    if let Some(matchups) = info_json["matchups"].as_array() {
169        for matchup in matchups {
170            if let Some(role) = matchup["role"].as_str() {
171                matchups_by_role.insert(role.to_uppercase(), matchup);
172            }
173        }
174    }
175    let mut embed = CreateEmbed::new()
176        .title(title)
177        .color(color)
178        .thumbnail(game_result_thumbnail);
179
180    for role in &roles_order {
181        if let Some(matchup) = matchups_by_role.get(&role.to_uppercase()) {
182            let team_player = &matchup["team"];
183            let enemy_player = &matchup["enemy"];
184            let role_label = match *role {
185                "TOP" => format!(
186                    "**{} TOP**\n",
187                    get_emoji(collection_emoji.clone(), "position", "TOP")
188                        .await
189                        .unwrap_or("🔼".to_string())
190                ),
191                "JUNGLE" => format!(
192                    "**{} JUNGLE**\n",
193                    get_emoji(collection_emoji.clone(), "position", "JUNGLE")
194                        .await
195                        .unwrap_or("🌲".to_string())
196                ),
197                "MIDDLE" => format!(
198                    "**{} MIDDLE**\n",
199                    get_emoji(collection_emoji.clone(), "position", "MIDDLE")
200                        .await
201                        .unwrap_or("🛣️".to_string())
202                ),
203                "BOTTOM" => format!(
204                    "**{} BOTTOM**\n",
205                    get_emoji(collection_emoji.clone(), "position", "BOTTOM")
206                        .await
207                        .unwrap_or("🔽".to_string())
208                ),
209                "UTILITY" => format!(
210                    "**{} SUPPORT**\n",
211                    get_emoji(collection_emoji.clone(), "position", "SUPPORT")
212                        .await
213                        .unwrap_or("🛡️".to_string())
214                ),
215                _ => "**UNKNOWN**\n".to_string(),
216            };
217
218            // Team player stats
219            let team_stats = format!(
220                "{} **{}**\nK/D/A: **{}/{}/{}** | CS: **{}** | Gold: {} | Vision: {}",
221                get_emoji(
222                    collection_emoji.clone(),
223                    "champions",
224                    team_player["championName"].as_str().unwrap_or("Unknown")
225                )
226                .await
227                .unwrap_or(
228                    team_player["championName"]
229                        .as_str()
230                        .unwrap_or("Unknown")
231                        .to_string()
232                ),
233                team_player["summonerName"].as_str().unwrap_or("Unknown"),
234                team_player["kills"].as_u64().unwrap_or(0),
235                team_player["deaths"].as_u64().unwrap_or(0),
236                team_player["assists"].as_u64().unwrap_or(0),
237                team_player["totalFarm"].as_u64().unwrap_or(0),
238                format_gold_k(team_player["goldEarned"].as_u64().unwrap_or(0)),
239                team_player["visionScore"].as_u64().unwrap_or(0)
240            );
241
242            // Enemy player stats
243            let enemy_stats = format!(
244                "{} **{}**\nK/D/A: **{}/{}/{}** | CS: **{}** | Gold: {} | Vision: {}",
245                get_emoji(
246                    collection_emoji.clone(),
247                    "champions",
248                    enemy_player["championName"].as_str().unwrap_or("Unknown")
249                )
250                .await
251                .unwrap_or(
252                    enemy_player["championName"]
253                        .as_str()
254                        .unwrap_or("Unknown")
255                        .to_string()
256                ),
257                enemy_player["summonerName"].as_str().unwrap_or("Unknown"),
258                enemy_player["kills"].as_u64().unwrap_or(0),
259                enemy_player["deaths"].as_u64().unwrap_or(0),
260                enemy_player["assists"].as_u64().unwrap_or(0),
261                enemy_player["totalFarm"].as_u64().unwrap_or(0),
262                format_gold_k(enemy_player["goldEarned"].as_u64().unwrap_or(0)),
263                enemy_player["visionScore"].as_u64().unwrap_or(0)
264            );
265
266            // Combine team and enemy stats
267            let field_value = format!("{}\n{}", team_stats, enemy_stats);
268
269            // Add the field to the embed
270            embed = embed.field(role_label, field_value, false);
271        }
272    }
273
274    embed
275}
276
277/// ⚙️ **Function**: Extracts key participant statistics from a match for a given player.
278///
279/// This function retrieves important statistics for a participant in a League of Legends match, such as their summoner name,
280/// champion name, kills, deaths, assists, total farm, gold earned, and vision score. The extracted stats are returned as a JSON object (`serde_json::Value`).
281///
282/// # Parameters:
283/// - `p`: A reference to a `serde_json::Value` object representing a participant in the match. This object contains all of the participant's stats and data.
284///
285/// # Returns:
286/// - `Value`: Returns a JSON object containing the player's stats, including their summoner name, champion name, K/D/A (kills, deaths, assists),
287/// total farm (minions and neutral monsters killed), gold earned, gold per minute, and vision score.
288///
289/// # Example:
290/// This function is used to format and extract individual player stats from the match data:
291///
292/// ```rust
293/// let player_stats = extract_participant_stats(&participant);
294/// println!("{}", player_stats["summonerName"]);
295/// ```
296///
297/// # Notes:
298/// - The summoner's name is prioritized over their Riot ID game name, but if the summoner name is missing, the Riot ID is used as a fallback.
299/// - Total farm is calculated as the sum of minions killed and neutral monsters killed.
300/// - The stats returned include the summoner's name, champion, K/D/A, farm, gold, gold per minute, and vision score, which are useful for comparing performance across teams.
301fn extract_participant_stats(p: &Value) -> Value {
302    let riot_id_game_name = p["riotIdGameName"].as_str().unwrap_or("Unknown");
303    let summoner_name = if p["summonerName"].as_str().unwrap_or("Unknown").is_empty() {
304        riot_id_game_name
305    } else {
306        p["summonerName"].as_str().unwrap_or("Unknown")
307    };
308    let champion_name = p["championName"].as_str().unwrap_or("Unknown");
309    let kills = p["kills"].as_u64().unwrap_or(0);
310    let deaths = p["deaths"].as_u64().unwrap_or(0);
311    let assists = p["assists"].as_u64().unwrap_or(0);
312    let total_minions_killed = p["totalMinionsKilled"].as_u64().unwrap_or(0);
313    let neutral_minions_killed = p["neutralMinionsKilled"].as_u64().unwrap_or(0);
314    let total_farm = total_minions_killed + neutral_minions_killed;
315    let gold_earned = p["goldEarned"].as_u64().unwrap_or(0);
316    let vision_score = p["visionScore"].as_u64().unwrap_or(0);
317
318    serde_json::json!({
319        "summonerName": summoner_name,
320        "championName": champion_name,
321        "kills": kills,
322        "deaths": deaths,
323        "assists": assists,
324        "totalFarm": total_farm,
325        "goldEarned": gold_earned,
326        "visionScore": vision_score
327    })
328}
329
330/// ⚙️ **Function**: Formats the amount of gold earned in a match into a more readable "k" notation when appropriate.
331///
332/// This function takes an amount of gold as input and formats it into a human-readable string. If the amount is less than 1000,
333/// it returns the gold value as a simple string. If the gold is 1000 or more, it formats the value in "k" notation (e.g., 1500 becomes "1.5k").
334///
335/// # Parameters:
336/// - `gold`: A `u64` value representing the amount of gold earned by a player in a match.
337///
338/// # Returns:
339/// - `String`: A formatted string representing the gold amount. If the amount is less than 1000, it returns the value as is.
340/// For amounts equal to or greater than 1000, it returns a string in "k" notation (e.g., "1k", "1.5k") with a comma used as the decimal separator.
341///
342/// # Example:
343/// ```rust
344/// let formatted_gold = format_gold_k(1500);
345/// assert_eq!(formatted_gold, "1,5k");
346/// ```
347///
348/// # Notes:
349/// - For gold values with no fractional part, the result will omit the decimal point (e.g., 1000 will be formatted as "1k" instead of "1.0k").
350/// - The function uses a comma to separate the decimal part, following European formatting conventions.
351fn format_gold_k(gold: u64) -> String {
352    if gold < 1000 {
353        gold.to_string()
354    } else {
355        let gold_f64 = (gold as f64) / 1000.0;
356        if gold_f64.fract() == 0.0 {
357            format!("{}k", gold_f64 as u64)
358        } else {
359            let formatted = format!("{:.1}", gold_f64).replace('.', ",");
360            format!("{}k", formatted)
361        }
362    }
363}
364
365/// ⚙️ **Function**: Retrieves all followed summoners from the database.
366///
367/// This asynchronous function queries the "follower_summoner" collection in the "stat-summoner" MongoDB database
368/// to retrieve all documents representing followed summoners. It collects each `SummonerFollowedData` into a vector.
369///
370/// # Parameters:
371/// - `collection`: A reference to the MongoDB collection containing `SummonerFollowedData` documents.
372///
373/// # Returns:
374/// - `Result<Vec<SummonerFollowedData>, mongodb::error::Error>`: A vector of followed summoners if successful, or an error if the query fails.
375///
376/// # ⚠️ Notes:
377/// - Prints an error message in French if a document retrieval fails.
378/// - Ensure that the `SummonerFollowedData` struct aligns with the collection's document structure.
379///
380/// # Example:
381/// ```rust
382/// let summoners = get_followed_summoners(&collection).await?;
383/// println!("Retrieved {} summoners.", summoners.len());
384/// ```
385pub async fn get_followed_summoners(
386    collection: &Collection<SummonerFollowedData>,
387) -> Result<Vec<SummonerFollowedData>, mongodb::error::Error> {
388    let mut cursor = collection.find(doc! {}).await?;
389    let mut followed_summoners = Vec::new();
390
391    while let Some(result) = cursor.next().await {
392        match result {
393            Ok(followed_summoner) => {
394                followed_summoners.push(followed_summoner);
395            }
396            Err(e) => {
397                log::error!("Erreur lors de la récupération d'un document : {:?}", e);
398            }
399        }
400    }
401
402    Ok(followed_summoners)
403}
404
405/// ⚙️ **Function**: Processes a followed summoner by checking if their follow time has expired or if they have played a new match.
406///
407/// This asynchronous function handles the logic for a followed summoner. It checks if the follow time has expired and removes the summoner from the database if necessary. If the follow time is still valid, it checks for new matches and updates the summoner's information accordingly.
408///
409/// # Parameters:
410/// - `collection`: A reference to a MongoDB `Collection<SummonerFollowedData>` that stores the followed summoners' data.
411/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct containing the summoner's information, including their follow duration and last match details.
412/// - `riot_api_key`: A string slice containing the Riot Games API key for authenticating the API request.
413/// - `http`: An `Arc<Http>` object used to send messages via the Discord API.
414/// - `collection_emojis`: A MongoDB `Collection` containing emoji mappings, used to enrich the Discord embeds with custom emojis for roles and champions.
415///
416/// # Returns:
417/// - `Result<(), Error>`: Returns `Ok(())` if the summoner was successfully processed (either by removing them from the database or updating their match info), or an error if something went wrong.
418///
419/// # Example:
420/// This function is typically called as part of a loop or scheduled task that checks the status of followed summoners:
421///
422/// ```rust
423/// let result = process_followed_summoner(collection, &followed_summoner, riot_api_key, http.clone(), collection_emojis).await;
424/// if result.is_err() {
425///     // Handle error (e.g., log failure or retry)
426/// }
427/// ```
428///
429/// # Notes:
430/// - The function begins by checking if the follow time for the summoner has expired using the `is_follow_time_expired` function.
431/// - If the follow time has expired, the summoner is removed from the MongoDB collection by calling `delete_follower`.
432/// - If the summoner is still being followed, the function calls `update_follower_if_new_match` to check for new matches and potentially send an update to the associated Discord channel.
433/// - This function ensures that summoners are only followed for the specified duration and that Discord channels are updated with relevant match information during the follow period.
434pub async fn process_followed_summoner(
435    collection: &Collection<SummonerFollowedData>,
436    followed_summoner: &SummonerFollowedData,
437    riot_api_key: &str,
438    http: Arc<Http>,
439    collection_emojis: Collection<EmojiId>,
440) -> Result<(), Error> {
441    if is_follow_time_expired(followed_summoner) {
442        delete_follower(collection, followed_summoner).await?;
443    } else {
444        update_follower_if_new_match(
445            collection,
446            followed_summoner,
447            riot_api_key,
448            http,
449            collection_emojis,
450        )
451        .await?;
452    }
453    Ok(())
454}
455
456/// ⚙️ **Function**: Determines if the follow time for a summoner has expired.
457///
458/// This function checks whether the current timestamp exceeds the stored follow end time for a summoner.
459/// It parses the `time_end_follow` field from the `SummonerFollowedData` struct, compares it to the current UTC timestamp,
460/// and returns `true` if the follow time has expired, or `false` otherwise.
461///
462/// # Parameters:
463/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct, which contains information about the summoner, including when the follow period ends.
464///
465/// # Returns:
466/// - `bool`: Returns `true` if the current time is greater than the stored `time_end_follow`, meaning the follow period has expired. Returns `false` if the follow period is still active.
467///
468/// # Example:
469/// This function is used to determine whether a summoner should be removed from the list of followed summoners.
470///
471/// ```rust
472/// let expired = is_follow_time_expired(&followed_summoner);
473/// if expired {
474///     // Remove summoner from database
475/// }
476/// ```
477///
478/// # Notes:
479/// - The function uses UTC time for comparison and assumes the `time_end_follow` is a valid timestamp that can be parsed into an `i64`. If parsing fails, it defaults to 0, which will always result in `true`.
480fn is_follow_time_expired(followed_summoner: &SummonerFollowedData) -> bool {
481    let time_end_follow = followed_summoner
482        .time_end_follow
483        .parse::<i64>()
484        .unwrap_or(0);
485    let current_timestamp = Utc::now().timestamp();
486    current_timestamp > time_end_follow
487}
488
489/// ⚙️ **Function**: Deletes a followed summoner from the database.
490///
491/// This asynchronous function removes a summoner from the `follower_summoner` collection in MongoDB based on their `puuid`.
492/// It logs the deletion action and ensures the summoner is no longer tracked in the database.
493///
494/// # Parameters:
495/// - `collection`: A reference to the MongoDB `Collection<SummonerFollowedData>`, used to interact with the database and delete the summoner data.
496/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct, representing the summoner that is being deleted. The deletion is based on the summoner's `puuid`.
497///
498/// # Returns:
499/// - `Result<(), mongodb::error::Error>`: Returns an empty result if successful, or an error if the deletion fails.
500///
501/// # Example:
502/// This function is typically called when the follow time for a summoner has expired, and they need to be removed from the database:
503///
504/// ```rust
505/// delete_follower(&collection, &followed_summoner).await?;
506/// ```
507///
508/// # Notes:
509/// - The `puuid` field is used as the unique identifier for deletion from the MongoDB collection.
510/// - The function logs the `puuid` of the summoner being deleted using `log::info!`, which outputs the message to the standard log stream.
511async fn delete_follower(
512    collection: &Collection<SummonerFollowedData>,
513    followed_summoner: &SummonerFollowedData,
514) -> Result<(), mongodb::error::Error> {
515    log::info!("Suppression de {}", followed_summoner.puuid);
516    collection
517        .delete_one(
518            doc! { "puuid": &followed_summoner.puuid, "guild_id": &followed_summoner.guild_id },
519        )
520        .await?;
521    Ok(())
522}
523
524/// ⚙️ **Function**: Updates a followed summoner's last match ID and sends a Discord update if a new match is detected.
525///
526/// This asynchronous function checks if a followed summoner has played a new match. If a new match is detected,
527/// it updates the summoner's last match ID in the MongoDB collection and sends a match update to the appropriate Discord channel.
528///
529/// # Parameters:
530/// - `collection`: A reference to a MongoDB `Collection<SummonerFollowedData>` that stores the followed summoners' data.
531/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct containing the summoner's information, including their PUUID, summoner ID, and last match ID.
532/// - `riot_api_key`: A string slice containing the Riot Games API key for authenticating the API request.
533/// - `http`: An `Arc<Http>` object used to send messages via the Discord API.
534/// - `collection_emojis`: A MongoDB `Collection` containing emoji mappings, used to enhance the Discord embed with custom emojis for roles and champions.
535///
536/// # Returns:
537/// - `Result<(), Error>`: Returns `Ok(())` if the last match ID was successfully updated and the match update was sent to Discord, or an error if something went wrong.
538///
539/// # Example:
540/// This function is typically called periodically to check if a followed summoner has played a new match:
541///
542/// ```rust
543/// let result = update_follower_if_new_match(collection, &followed_summoner, riot_api_key, http.clone(), collection_emojis).await;
544/// if result.is_err() {
545///     // Handle error (e.g., log failure or retry)
546/// }
547/// ```
548///
549/// # Notes:
550/// - The function begins by creating an HTTP client using `reqwest` and fetching the latest match ID for the summoner using the `get_latest_match_id` function.
551/// - If the new match ID is different from the stored `last_match_id`, the function updates the MongoDB collection with the new match ID.
552/// - Once the database is updated, the function calls `send_match_update_to_discord` to send a match update to the Discord channel associated with the summoner.
553/// - This function ensures that the Discord server is notified whenever the summoner completes a new match, keeping followers updated in real time.
554async fn update_follower_if_new_match(
555    collection: &Collection<SummonerFollowedData>,
556    followed_summoner: &SummonerFollowedData,
557    riot_api_key: &str,
558    http: Arc<Http>,
559    collection_emojis: Collection<EmojiId>,
560) -> Result<(), Error> {
561    let puuid = &followed_summoner.puuid;
562    let last_match_id = &followed_summoner.last_match_id;
563    let guild_id = &followed_summoner.guild_id;
564    let client = reqwest::Client::new();
565
566    let match_id_from_riot = get_latest_match_id(&client, puuid, riot_api_key).await?;
567
568    if last_match_id != &match_id_from_riot {
569        collection
570            .update_one(
571                doc! {
572                "puuid": puuid,
573                "guild_id": guild_id
574                },
575                doc! { "$set": { "last_match_id": &match_id_from_riot } },
576            )
577            .await?;
578        send_match_update_to_discord(
579            followed_summoner,
580            puuid,
581            &match_id_from_riot,
582            riot_api_key,
583            http,
584            collection_emojis,
585        )
586        .await?;
587    }
588    Ok(())
589}
590
591/// ⚙️ **Function**: Fetches the latest match ID for a given summoner using their PUUID.
592///
593/// This asynchronous function retrieves the most recent match ID for a summoner by making a request to the Riot API.
594/// It uses the summoner's `puuid` to query their match history and returns the match ID of the most recent game.
595///
596/// # Parameters:
597/// - `client`: A reference to the `reqwest::Client`, used to make HTTP requests to the Riot API.
598/// - `puuid`: A string slice representing the summoner's PUUID (a unique identifier for each player in Riot's system).
599/// - `riot_api_key`: A string slice representing the Riot API key, used for authorized requests.
600///
601/// # Returns:
602/// - `Result<String, Error>`: Returns the latest match ID as a string if successful, or an error if the request or retrieval fails.
603///
604/// # Example:
605/// This function is typically used to get the latest match ID for a summoner in order to check for new matches:
606///
607/// ```rust
608/// let latest_match_id = get_latest_match_id(&client, puuid, riot_api_key).await?;
609/// ```
610///
611/// # Notes:
612/// - The function calls `get_matchs_id` to retrieve the match history and then returns the first match in the list, which corresponds to the most recent match.
613/// - The `get_matchs_id` function is expected to return a vector of match IDs, from which the latest match (the first one) is extracted and returned.
614async fn get_latest_match_id(
615    client: &reqwest::Client,
616    puuid: &str,
617    riot_api_key: &str,
618) -> Result<String, Error> {
619    let matches = get_matchs_id(client, puuid, riot_api_key, 1).await?;
620    Ok(matches[0].clone())
621}
622
623/// ⚙️ **Function**: Sends a match update to a specific Discord channel for a followed summoner.
624///
625/// This asynchronous function fetches match information for a followed summoner from the Riot API,
626/// formats the details into an embed, and sends the embed as a message to the specified Discord channel.
627///
628/// # Parameters:
629/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct, which contains the summoner's name and the ID of the Discord channel to which the match update should be sent.
630/// - `summoner_id`: A string slice representing the summoner's ID, used to identify the player's stats in the match.
631/// - `match_id`: A string slice representing the match ID, used to fetch match details from the Riot API.
632/// - `riot_api_key`: A string slice containing the Riot Games API key for authenticating the API request.
633/// - `http`: An `Arc<Http>` object used to send messages via the Discord API.
634/// - `collection_emojis`: A MongoDB `Collection` containing emoji mappings, used to add custom emojis to the embed for roles and champions.
635///
636/// # Returns:
637/// - `Result<(), Error>`: Returns `Ok(())` if the match update was successfully sent to the Discord channel, or an error if something went wrong.
638///
639/// # Example:
640/// This function is typically called after detecting that a followed summoner has completed a match:
641///
642/// ```rust
643/// let result = send_match_update_to_discord(&followed_summoner, summoner_id, match_id, riot_api_key, http.clone(), collection_emojis).await;
644/// if result.is_err() {
645///     // Handle error (e.g., log failure or retry)
646/// }
647/// ```
648///
649/// # Notes:
650/// - The function creates an HTTP client using `reqwest` to fetch match information from the Riot API.
651/// - It retrieves detailed match data using the `get_matchs_info` and `get_match_details` functions.
652/// - The function constructs a `CreateEmbed` object using the `create_embed_loop` function, which formats match statistics and adds emojis.
653/// - The embed is sent as a message to the Discord channel specified in the `followed_summoner` struct.
654/// - The Discord message is built using `CreateMessage` and sent asynchronously to the appropriate channel using the Discord API.
655async fn send_match_update_to_discord(
656    followed_summoner: &SummonerFollowedData,
657    puuid: &str,
658    match_id: &str,
659    riot_api_key: &str,
660    http: Arc<Http>,
661    collection_emojis: Collection<EmojiId>,
662) -> Result<(), Error> {
663    let client = reqwest::Client::new();
664    let info = get_matchs_info(&client, match_id, riot_api_key).await?;
665    let info_json = get_match_details(&info, puuid).unwrap();
666    let channel_id = serenity::model::id::ChannelId::new(followed_summoner.channel_id);
667    let embed = create_embed_loop(&info_json, &followed_summoner.name, collection_emojis).await;
668    let builder = CreateMessage::new().add_embed(embed);
669    let _ = channel_id.send_message(&http, builder).await;
670    Ok(())
671}
672
673/// ⚙️ **Function**: Fetches rune data for a specific champion from League of Graphs.
674///
675/// This asynchronous function retrieves the rune build information for a given champion
676/// by making an HTTP request to League of Graphs. It then parses the HTML response to
677/// extract the rune tables and returns the runes in the `RunesData` structure.
678///
679/// # Parameters:
680/// - `champion_id`: A string slice representing the champion's identifier, used to build the URL for fetching the rune information.
681///
682/// # Returns:
683/// - `Result<RunesData, Error>`: Returns a `RunesData` struct with the champion's rune information if successful, or an error if something goes wrong during the HTTP request or parsing.
684///
685/// # Example:
686/// This function is typically called to retrieve the rune data for a specific champion:
687///
688/// ```rust
689/// let runes = fetch_runes("Rammus").await?;
690/// println!("{:?}", runes);
691/// ```
692///
693/// # Notes:
694/// - The function makes an HTTP request to the League of Graphs page using the champion's ID to construct the URL.
695/// - It then parses the HTML to find the rune tables and extracts the relevant rune data.
696/// - The `extract_runes` function is used to process the HTML and return the rune information in the `RunesData` structure.
697/// - This function expects two rune tables (primary and secondary) to be present in the response, otherwise it will panic with an `unwrap()` error.
698pub async fn fetch_runes(champion_id: &str) -> Result<RunesData, Error> {
699    let url = format!(
700        "https://www.leagueofgraphs.com/champions/builds/{}",
701        champion_id
702    );
703    let client = reqwest::Client::new();
704    let res = client
705        .get(&url)
706        .header("User-Agent", "Mozilla/5.0")
707        .send()
708        .await?;
709    let body = res.text().await?;
710    let document = Document::from(body.as_str());
711
712    // Logique pour extraire les runes, en utilisant `RunesData` comme la structure finale
713    let first_rune_table = document
714        .find(Class("perksTableOverview"))
715        .next()
716        .ok_or_else(|| {
717            Box::<dyn std::error::Error + Send + Sync>::from(
718                "Erreur: Impossible de trouver la première table de runes",
719            )
720        })?;
721    let secondary_rune_table = document
722        .find(Class("perksTableOverview"))
723        .nth(1)
724        .ok_or_else(|| {
725            Box::<dyn std::error::Error + Send + Sync>::from(
726                "Erreur: Impossible de trouver la deuxième table de runes",
727            )
728        })?;
729
730    let runes = extract_runes(first_rune_table, secondary_rune_table);
731    Ok(runes)
732}
733
734/// ⚙️ **Function**: Fetches core build data for a specific champion from League of Graphs.
735///
736/// This asynchronous function retrieves the core build item information for a given champion
737/// by making an HTTP request to League of Graphs. It then parses the HTML response to locate
738/// the core build section and extracts the items used in the build.
739///
740/// # Parameters:
741/// - `champion_id`: A string slice representing the champion's identifier, used to build the URL for fetching the core build information.
742///
743/// # Returns:
744/// - `Result<CoreBuildData, Error>`: Returns a `CoreBuildData` struct containing the champion's core build items if successful, or an error if something goes wrong during the HTTP request or parsing.
745///
746/// # Example:
747/// This function is typically called to retrieve the core build data for a specific champion:
748///
749/// ```rust
750/// let core_build = fetch_core_build("Jinx").await?;
751/// println!("{:?}", core_build);
752/// ```
753///
754/// # Notes:
755/// - The function makes an HTTP request to the League of Graphs page using the champion's ID to construct the URL.
756/// - It parses the HTML to find the core build section by searching for an `h3` element containing the text "Core Build".
757/// - Once the core build header is found, the function searches for its parent element and the `iconsRow` div where the build items are listed.
758/// - The function calls `extract_core_build` to process the icons and return the items in the `CoreBuildData` structure.
759/// - If the core build header or the `iconsRow` div is not found, an error is returned.
760pub async fn fetch_core_build(champion_id: &str) -> Result<CoreBuildData, Error> {
761    let url = format!(
762        "https://www.leagueofgraphs.com/champions/builds/{}",
763        champion_id
764    );
765    let client = reqwest::Client::new();
766    let res = client
767        .get(&url)
768        .header("User-Agent", "Mozilla/5.0")
769        .send()
770        .await?;
771    let body = res.text().await?;
772    let document = select::document::Document::from(body.as_str());
773
774    if let Some(core_build_header) = document
775        .find(Name("h3"))
776        .find(|node| node.text().contains("Core Build"))
777    {
778        if let Some(parent_div) = core_build_header.parent() {
779            if let Some(icons_row) = parent_div.find(Class("iconsRow")).next() {
780                let core_build = extract_core_build(icons_row);
781                return Ok(core_build);
782            } else {
783                return Err(Box::from("Erreur: Impossible de trouver 'iconsRow'"));
784            }
785        } else {
786            return Err(Box::from(
787                "Erreur: Impossible de trouver le parent de 'Core Build'",
788            ));
789        }
790    } else {
791        return Err(Box::from(
792            "Erreur: Impossible de trouver le header 'Core Build'",
793        ));
794    }
795}
796
797/// ⚙️ **Function**: Extracts rune data from two HTML tables.
798///
799/// This function processes two HTML tables (representing primary and secondary runes) and extracts
800/// the rune images by filtering out those that are not visible (with `opacity: 0.2`) and by cleaning
801/// the `alt` attributes of the remaining images. The function then constructs a `RunesData` struct
802/// containing all rune data if exactly 9 runes are found.
803///
804/// # Parameters:
805/// - `first_table`: A `select::node::Node` representing the primary rune table.
806/// - `second_table`: A `select::node::Node` representing the secondary rune table.
807///
808/// # Returns:
809/// - `RunesData`: Returns a `RunesData` struct containing the extracted rune information. If the number of
810/// runes found is not exactly 9, it returns a `RunesData` struct with empty strings for all fields.
811///
812/// # Example:
813/// This function is typically called to process rune tables extracted from a web page:
814///
815/// ```rust
816/// let runes_data = extract_runes(primary_table, secondary_table);
817/// println!("{:?}", runes_data);
818/// ```
819///
820/// # Notes:
821/// - The function first collects all `img` tags from both the primary and secondary rune tables.
822/// - It filters out images that have a parent `div` with `opacity: 0.2` and skips any image without a valid `alt` attribute.
823/// - The `clean_alt_text` function is applied to clean up the `alt` text before it is added to the final rune list.
824/// - The function expects exactly 9 runes: 4 primary runes, 2 secondary runes, and 3 tertiary runes. If this condition is not met, an empty `RunesData` struct is returned.
825fn extract_runes(first_table: select::node::Node, second_table: select::node::Node) -> RunesData {
826    let images = first_table
827        .find(Name("img"))
828        .chain(second_table.find(Name("img")))
829        .filter_map(|img| {
830            if let Some(parent_div) = img.parent() {
831                if parent_div.attr("style") != Some("opacity: 0.2;") {
832                    if let Some(alt) = img.attr("alt") {
833                        if !alt.trim().is_empty() {
834                            return Some(clean_alt_text(alt));
835                        }
836                    }
837                }
838            }
839            None
840        })
841        .collect::<Vec<String>>();
842
843    if images.len() == 9 {
844        RunesData {
845            parent_primary_rune: images[0].clone(),
846            child_primary_rune_1: images[1].clone(),
847            child_primary_rune_2: images[2].clone(),
848            child_primary_rune_3: images[3].clone(),
849            child_secondary_rune_1: images[4].clone(),
850            child_secondary_rune_2: images[5].clone(),
851            tertiary_rune_1: images[6].clone(),
852            tertiary_rune_2: images[7].clone(),
853            tertiary_rune_3: images[8].clone(),
854        }
855    } else {
856        RunesData {
857            parent_primary_rune: String::new(),
858            child_primary_rune_1: String::new(),
859            child_primary_rune_2: String::new(),
860            child_primary_rune_3: String::new(),
861            child_secondary_rune_1: String::new(),
862            child_secondary_rune_2: String::new(),
863            tertiary_rune_1: String::new(),
864            tertiary_rune_2: String::new(),
865            tertiary_rune_3: String::new(),
866        }
867    }
868}
869
870/// ⚙️ **Function**: Extracts the core build items from an HTML `iconsRow` div.
871///
872/// This function processes a div containing the core build items for a champion, typically found
873/// in the `iconsRow` HTML element. It extracts the `alt` attributes of the item images, cleans them,
874/// and returns the first, second, and third items in the build as a `CoreBuildData` struct.
875///
876/// # Parameters:
877/// - `icons_row`: A `select::node::Node` representing the `iconsRow` div that contains the item icons for the core build.
878///
879/// # Returns:
880/// - `CoreBuildData`: Returns a `CoreBuildData` struct containing the names of the first, second, and third core build items.
881///
882/// # Example:
883/// This function is typically called to process the core build for a specific champion:
884///
885/// ```rust
886/// let core_build = extract_core_build(icons_row);
887/// println!("{:?}", core_build);
888/// ```
889///
890/// # Notes:
891/// - The function collects all `img` tags within the `iconsRow` div and extracts the `alt` attributes, which contain the names of the items.
892/// - The `clean_alt_text` function is used to clean the `alt` text by removing unnecessary characters and formatting it.
893/// - The function assumes that the images vector contains at least four elements, where the first image is ignored and the second, third, and fourth images represent the core build items.
894/// - If the `iconsRow` div does not contain enough images, this could result in an `index out of bounds` error, so ensure the data is well-formed before calling the function.
895fn extract_core_build(icons_row: select::node::Node) -> CoreBuildData {
896    let images = icons_row
897        .find(Name("img"))
898        .filter_map(|img| img.attr("alt"))
899        .map(clean_alt_text)
900        .collect::<Vec<String>>();
901    CoreBuildData {
902        first: images[1].clone(),
903        second: images[2].clone(),
904        third: images[3].clone(),
905    }
906}
907
908/// ⚙️ **Function**: Cleans the alt text for an item or rune and applies special formatting.
909///
910/// This function processes the `alt` attribute text extracted from HTML elements, removes unwanted characters,
911/// and applies special rules. If the `alt` text contains certain characters (parentheses, hyphens, and plus signs),
912/// it returns "HealthScale". Otherwise, it cleans the text by removing parentheses, numbers, and specific symbols.
913///
914/// # Parameters:
915/// - `alt`: A string slice representing the `alt` text from an HTML `img` tag that needs to be cleaned.
916///
917/// # Returns:
918/// - `String`: Returns a cleaned version of the `alt` text. If the `alt` text matches specific patterns (parentheses, hyphen, and plus signs),
919/// it returns "HealthScale". Otherwise, it returns the cleaned text with unwanted characters removed.
920///
921/// # Example:
922/// This function is typically called to clean the text from the `alt` attributes of item or rune images:
923///
924/// ```rust
925/// let clean_text = clean_alt_text("Health (100) + 10% - 5%");
926/// println!("{}", clean_text); // Output: "HealthScale"
927///
928/// let clean_text = clean_alt_text("Sunfire Aegis");
929/// println!("{}", clean_text); // Output: "SunfireAegis"
930/// ```
931///
932/// # Notes:
933/// - If the `alt` text contains parentheses `()`, a hyphen `-`, and a plus sign `+`, the function returns "HealthScale".
934/// - It uses regular expressions to remove unwanted characters such as parentheses, numbers, percentage symbols, commas, and others.
935/// - Spaces are also removed in the final output.
936fn clean_alt_text(alt: &str) -> String {
937    if alt.contains('(') && alt.contains(')') && alt.contains('-') && alt.contains('+') {
938        return "HealthScale".to_string();
939    }
940    let re_parentheses = Regex::new(r"\(.*?\)").unwrap();
941    let cleaned_alt = re_parentheses.replace_all(alt, "");
942    let re_unwanted = Regex::new(r"[+:1234567890%-',]").unwrap();
943    let cleaned_alt = re_unwanted.replace_all(&cleaned_alt, "").trim().to_string();
944
945    cleaned_alt.replace(" ", "")
946}
947
948#[derive(Debug, Clone, Default)]
949pub struct ChampionInfo {
950    pub champion_name: String,
951    pub popularity_winrate: String,
952    pub popularity_played_percentage: String,
953    pub ban_rate: String,
954    pub roles: Vec<String>,
955    pub champion_link: String,
956}
957
958/// Extrait le tableau JSON associé à `key` (ex: "rankings") même s'il est imbriqué/multiligne,
959/// puis retourne une liste de `ChampionInfo`.
960/// Si un champion apparaît plusieurs fois, on fusionne ses rôles (sans doublons).
961pub fn extract_info_from_json_arr(s: &str, key: &str) -> Vec<ChampionInfo> {
962    let mut out: Vec<ChampionInfo> = Vec::new();
963    let mut index_by_name: HashMap<String, usize> = HashMap::new();
964
965    let bytes = s.as_bytes();
966    let needle = format!(r#""{}""#, key);
967
968    // 1) trouver "key"
969    let key_pos = match s.find(&needle) {
970        Some(p) => p,
971        None => return out, // clé introuvable -> liste vide
972    };
973
974    // 2) trouver le ':' qui suit
975    let after_key = &s[key_pos + needle.len()..];
976    let colon_rel = match after_key.find(':') {
977        Some(c) => c,
978        None => return out,
979    };
980    let mut i = key_pos + needle.len() + colon_rel + 1;
981
982    // 3) avancer jusqu'au premier '['
983    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
984        i += 1;
985    }
986    if i >= bytes.len() || bytes[i] != b'[' {
987        if let Some(next_bracket) = bytes[i..].iter().position(|&b| b == b'[') {
988            i += next_bracket;
989        } else {
990            return out; // pas de tableau après la clé
991        }
992    }
993
994    // 4) parcourir en comptant les crochets, en ignorant les chaînes
995    let mut depth: i32 = 0;
996    let mut in_string = false;
997    let mut escape = false;
998
999    let mut j = i;
1000    while j < bytes.len() {
1001        let b = bytes[j];
1002
1003        if in_string {
1004            if escape {
1005                escape = false;
1006            } else if b == b'\\' {
1007                escape = true;
1008            } else if b == b'"' {
1009                in_string = false;
1010            }
1011        } else {
1012            match b {
1013                b'"' => in_string = true,
1014                b'[' => depth += 1,
1015                b']' => {
1016                    depth -= 1;
1017                    if depth == 0 {
1018                        // On a trouvé la fin du tableau
1019                        let slice = &s[i..=j];
1020
1021                        // Parse le tableau JSON
1022                        if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(slice) {
1023                            for item in items {
1024                                if let Value::Object(obj) = item {
1025                                    let champion_name = obj
1026                                        .get("championName")
1027                                        .and_then(|v| v.as_str())
1028                                        .unwrap_or("<unknown>")
1029                                        .to_string();
1030
1031                                    // popularity.winrate peut être "winrate" ou "winRate", nombre ou string
1032                                    let popularity_winrate = obj
1033                                        .get("popularity")
1034                                        .and_then(|p| p.get("winrate").or_else(|| p.get("winRate")))
1035                                        .and_then(|v| v.as_f64())
1036                                        .map(|f| format!("{:.4}", f))
1037                                        .unwrap_or_default();
1038
1039                                    let popularity_played_percentage = obj
1040                                        .get("popularity")
1041                                        .and_then(|p| p.get("playedPercentage"))
1042                                        .and_then(|v| v.as_f64())
1043                                        .map(|f| format!("{:.4}", f))
1044                                        .unwrap_or_default();
1045
1046                                    let ban_rate = obj
1047                                        .get("banRate")
1048                                        .and_then(|v| v.as_f64())
1049                                        .map(|f| format!("{:.4}", f))
1050                                        .unwrap_or_default();
1051
1052                                    let role_title = obj
1053                                        .get("role")
1054                                        .and_then(|r| r.get("title"))
1055                                        .and_then(|v| v.as_str())
1056                                        .unwrap_or_default()
1057                                        .to_string();
1058
1059                                    let champion_link = obj
1060                                        .get("championLink")
1061                                        .and_then(|v| v.as_str())
1062                                        .map(|s| {
1063                                            // Supprime le préfixe "/champions/builds/" s'il existe
1064                                            s.strip_prefix("/champions/builds/")
1065                                                .unwrap_or(s)
1066                                                .to_string()
1067                                        })
1068                                        .unwrap_or_default();
1069
1070                                    // Fusion dans la liste : si champion déjà présent, merge des rôles
1071                                    if let Some(&idx) = index_by_name.get(&champion_name) {
1072                                        // mettre à jour les champs "non rôles" si vides (on ne sait pas s'il faut écraser)
1073                                        if out[idx].popularity_winrate.is_empty()
1074                                            && !popularity_winrate.is_empty()
1075                                        {
1076                                            out[idx].popularity_winrate =
1077                                                popularity_winrate.clone();
1078                                        }
1079                                        if out[idx].popularity_played_percentage.is_empty()
1080                                            && !popularity_played_percentage.is_empty()
1081                                        {
1082                                            out[idx].popularity_played_percentage =
1083                                                popularity_played_percentage.clone();
1084                                        }
1085                                        if out[idx].ban_rate.is_empty() && !ban_rate.is_empty() {
1086                                            out[idx].ban_rate = ban_rate.clone();
1087                                        }
1088                                        if out[idx].champion_link.is_empty()
1089                                            && !champion_link.is_empty()
1090                                        {
1091                                            out[idx].champion_link = champion_link.clone();
1092                                        }
1093
1094                                        // merge du rôle si différent et non vide
1095                                        if !role_title.is_empty()
1096                                            && !out[idx].roles.iter().any(|r| r == &role_title)
1097                                        {
1098                                            out[idx].roles.push(role_title);
1099                                        }
1100                                    } else {
1101                                        let mut roles = Vec::new();
1102                                        if !role_title.is_empty() {
1103                                            roles.push(role_title);
1104                                        }
1105
1106                                        let info = ChampionInfo {
1107                                            champion_name: champion_name.clone(),
1108                                            popularity_winrate,
1109                                            popularity_played_percentage,
1110                                            ban_rate,
1111                                            roles,
1112                                            champion_link,
1113                                        };
1114                                        out.push(info);
1115                                        index_by_name.insert(champion_name, out.len() - 1);
1116                                    }
1117                                }
1118                            }
1119                        }
1120
1121                        break; // on a traité le tableau; on peut sortir
1122                    }
1123                }
1124                _ => {}
1125            }
1126        }
1127        j += 1;
1128    }
1129
1130    out
1131}