1use crate::constants::{
7 DEFAULT_CURRENCY, RAWG_RATE_LIMIT_MS, STEAM_CDN_AKAMAI_URL, STEAM_HEADER_IMAGE_PATH,
8 STEAM_STORE_URL,
9};
10use crate::database::{self, AppState};
11use crate::errors::AppError;
12use crate::models::WishlistGame;
13use crate::services::integration::gamebrain::{self, GameBrainSearchParams};
14use crate::services::integration::{itad, rawg};
15use chrono::NaiveDate;
16use rusqlite::{params, Connection};
17use serde::Deserialize;
18use std::fs;
19use std::time::Duration;
20use tauri::{AppHandle, Emitter, Manager, State};
21use tokio::time::sleep;
22use tracing::{error, info};
23
24#[derive(serde::Serialize)]
26pub struct SearchResult {
27 pub id: String,
28 pub name: String,
29 pub cover_url: Option<String>,
30}
31
32fn insert_game_internal(conn: &Connection, game: &WishlistGame) -> Result<(), AppError> {
37 conn.execute(
38 "INSERT OR REPLACE INTO wishlist (
39 id, name, cover_url, store_url, store_platform,
40 current_price, normal_price, lowest_price,
41 currency, on_sale, voucher, itad_id, added_at
42 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
43 params![
44 game.id,
45 game.name,
46 game.cover_url,
47 game.store_url,
48 game.store_platform,
49 game.current_price,
50 game.normal_price,
51 game.lowest_price,
52 game.currency,
53 game.on_sale,
54 game.voucher,
55 game.itad_id,
56 game.added_at
57 ],
58 )?;
59 Ok(())
60}
61
62#[derive(Deserialize)]
65struct SteamExportRoot {
66 data: Vec<SteamExportItem>,
67}
68
69#[derive(Deserialize)]
70struct SteamExportItem {
71 title: String,
72 gameid: Vec<String>, price: Option<String>, added_date: Option<String>, }
76
77#[derive(Deserialize)]
78struct ItadExportRoot {
79 data: ItadDataWrapper,
80}
81
82#[derive(Deserialize)]
83struct ItadDataWrapper {
84 data: Vec<ItadGroup>,
85}
86
87#[derive(Deserialize)]
88struct ItadGroup {
89 games: Vec<ItadGame>,
90}
91
92#[derive(Deserialize)]
93struct ItadGame {
94 id: String, title: String,
96 added: i64, }
98
99fn parse_steam_price(price_str: Option<&String>) -> Option<f64> {
100 price_str.as_ref().and_then(|s| {
102 let clean = s.replace("R$", "").replace(' ', "").replace(',', ".");
103 clean.parse::<f64>().ok()
104 })
105}
106
107fn parse_steam_date(date_str: Option<&String>) -> String {
108 if let Some(s) = date_str {
109 if let Ok(date) = NaiveDate::parse_from_str(s, "%d/%m/%Y") {
111 if let Some(datetime) = date.and_hms_opt(0, 0, 0) {
112 return datetime.and_utc().to_rfc3339();
113 }
114 }
115 }
116 chrono::Utc::now().to_rfc3339()
117}
118
119fn parse_steam_wishlist(content: &str) -> Option<Vec<WishlistGame>> {
121 let export: SteamExportRoot = serde_json::from_str(content).ok()?;
122 let mut games = Vec::new();
123
124 for item in export.data {
125 let app_id = item
127 .gameid
128 .get(1)
129 .and_then(|s| s.strip_prefix("app/"))
130 .unwrap_or("0")
131 .to_string();
132
133 let price = parse_steam_price(item.price.as_ref());
134
135 let cover_url = format!(
137 "{}/{}",
138 STEAM_CDN_AKAMAI_URL,
139 STEAM_HEADER_IMAGE_PATH.replace("{}", &app_id)
140 );
141
142 games.push(WishlistGame {
143 id: app_id.clone(),
144 name: item.title,
145 cover_url: Some(cover_url),
146 store_url: Some(format!("{}/app/{}", STEAM_STORE_URL, app_id)),
147 store_platform: Some("Steam".to_string()),
148 itad_id: None,
149 current_price: price,
150 normal_price: price,
151 lowest_price: price,
152 currency: Some(DEFAULT_CURRENCY.to_string()),
153 on_sale: false,
154 voucher: None,
155 added_at: Some(parse_steam_date(item.added_date.as_ref())),
156 });
157 }
158 Some(games)
159}
160
161fn parse_itad_wishlist(content: &str) -> Option<Vec<WishlistGame>> {
163 let export: ItadExportRoot = serde_json::from_str(content).ok()?;
164 let mut games = Vec::new();
165
166 for group in export.data.data {
167 for item in group.games {
168 let added_at = chrono::DateTime::from_timestamp(item.added, 0)
170 .map(|dt| dt.to_rfc3339())
171 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
172
173 games.push(WishlistGame {
174 id: item.id, name: item.title,
176 cover_url: None, store_url: None,
178 store_platform: Some("ITAD".to_string()),
179 itad_id: None,
180 current_price: None,
181 normal_price: None,
182 lowest_price: None,
183 currency: Some("BRL".to_string()),
184 on_sale: false,
185 voucher: None,
186 added_at: Some(added_at),
187 });
188 }
189 }
190 Some(games)
191}
192
193#[tauri::command]
195pub async fn import_wishlist(
196 state: State<'_, AppState>,
197 file_path: String,
198) -> Result<usize, AppError> {
199 let content = fs::read_to_string(&file_path)?;
201
202 let games = if let Some(steam_games) = parse_steam_wishlist(&content) {
204 steam_games
205 } else if let Some(itad_games) = parse_itad_wishlist(&content) {
206 itad_games
207 } else {
208 return Err(AppError::ValidationError(
209 "Formato de arquivo não reconhecido.".to_string(),
210 ));
211 };
212
213 let total = games.len();
214 if total == 0 {
215 return Ok(0);
216 }
217
218 {
220 let mut conn = state.games_db.lock()?;
221 let tx = conn.transaction()?;
222
223 for game in games {
224 insert_game_internal(&tx, &game)?;
225 }
226 tx.commit()?;
227 }
228
229 Ok(total)
230}
231
232#[tauri::command]
234pub async fn fetch_wishlist_covers(app: AppHandle) -> Result<(), AppError> {
235 let api_key = database::get_secret(&app, "rawg_api_key")?;
237 if api_key.is_empty() {
238 return Err(AppError::ValidationError(
239 "API Key da RAWG não configurada.".to_string(),
240 ));
241 }
242
243 tauri::async_runtime::spawn(async move {
245 let state: State<AppState> = app.state();
246
247 let missing_covers: Vec<(String, String)> = {
249 let conn = state.games_db.lock().unwrap();
250 let mut stmt = conn
251 .prepare("SELECT id, name FROM wishlist WHERE cover_url IS NULL OR cover_url = ''")
252 .unwrap();
253
254 stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
255 .unwrap()
256 .flatten()
257 .collect()
258 };
259
260 if missing_covers.is_empty() {
261 return;
262 }
263
264 let mut updated_count = 0;
265
266 for (id, name) in missing_covers {
268 match rawg::search_games(&api_key, &name).await {
269 Ok(results) => {
270 if let Some(first_match) = results.iter().find(|g| g.background_image.is_some())
272 {
273 if let Some(cover) = &first_match.background_image {
274 if let Ok(conn) = state.games_db.lock() {
275 if conn
276 .execute(
277 "UPDATE wishlist SET cover_url = ?1 WHERE id = ?2",
278 params![cover, id],
279 )
280 .is_ok()
281 {
282 updated_count += 1;
283 }
284 }
285 }
286 }
287 }
288 Err(e) => error!("Erro RAWG para '{}': {}", name, e),
289 }
290
291 sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
293 }
294
295 if updated_count > 0 {
297 info!("{} capas atualizadas", updated_count);
298 }
299 let _ = app.emit("wishlist_updated", ());
300 });
301
302 Ok(())
303}
304
305#[tauri::command]
309pub async fn search_wishlist_game(
310 app: AppHandle,
311 query: String,
312) -> Result<Vec<SearchResult>, AppError> {
313 let api_key = database::get_secret(&app, "rawg_api_key")?;
315 if api_key.is_empty() {
316 return Err(AppError::ValidationError(
317 "Configure a chave da RAWG nas configurações.".to_string(),
318 ));
319 }
320
321 let results = rawg::search_games(&api_key, &query)
322 .await
323 .map_err(AppError::NetworkError)?;
324
325 Ok(results
326 .into_iter()
327 .map(|g| SearchResult {
328 id: g.id.to_string(),
329 name: g.name,
330 cover_url: g.background_image,
331 })
332 .collect())
333}
334
335#[tauri::command]
344pub async fn search_wishlist_game_by_features(
345 app: AppHandle,
346 query: String,
347) -> Result<Vec<SearchResult>, AppError> {
348 let results = gamebrain::search_pc_games_by_features(
349 &app,
350 &query,
351 GameBrainSearchParams {
352 sort: Some(gamebrain::GameBrainSort::Rating),
353 sort_order: Some(gamebrain::GameBrainSortOrder::Desc),
354 limit: Some(20),
355 ..Default::default()
356 },
357 )
358 .await
359 .map_err(AppError::NetworkError)?;
360
361 Ok(results
362 .into_iter()
363 .map(|g| SearchResult {
364 id: g.id,
365 name: g.name,
366 cover_url: g.cover_url,
367 })
368 .collect())
369}
370
371#[tauri::command]
373pub fn add_to_wishlist(
374 state: State<AppState>,
375 id: String,
376 name: String,
377 cover_url: Option<String>,
378 store_url: Option<String>,
379 current_price: Option<f64>,
380 itad_id: Option<String>,
381) -> Result<String, AppError> {
382 let game = WishlistGame {
383 id,
384 name,
385 cover_url,
386 store_url,
387 store_platform: None,
388 itad_id,
389 current_price,
390 normal_price: current_price,
391 lowest_price: current_price,
392 currency: Some(DEFAULT_CURRENCY.to_string()),
393 on_sale: false,
394 voucher: None,
395 added_at: Some(chrono::Utc::now().to_rfc3339()),
396 };
397
398 let conn = state.games_db.lock()?;
399
400 insert_game_internal(&conn, &game)?;
401
402 Ok("Adicionado à Wishlist!".to_string())
403}
404
405#[tauri::command]
407pub fn remove_from_wishlist(state: State<AppState>, id: String) -> Result<String, AppError> {
408 let conn = state.games_db.lock()?;
409
410 conn.execute("DELETE FROM wishlist WHERE id = ?1", params![id])?;
411
412 Ok("Jogo removido da lista de desejos.".to_string())
413}
414
415#[tauri::command]
417pub fn get_wishlist(state: State<AppState>) -> Result<Vec<WishlistGame>, AppError> {
418 let conn = state.games_db.lock()?;
419
420 let mut stmt = conn
421 .prepare("SELECT id, name, cover_url, store_url, store_platform, current_price, normal_price, lowest_price, currency, on_sale, voucher, added_at, itad_id FROM wishlist ORDER BY added_at DESC")?;
422
423 let games = stmt
424 .query_map([], |row| {
425 Ok(WishlistGame {
426 id: row.get(0)?,
427 name: row.get(1)?,
428 cover_url: row.get(2)?,
429 store_url: row.get(3)?,
430 store_platform: row.get(4)?,
431 current_price: row.get(5)?,
432 normal_price: row.get(6)?,
433 lowest_price: row.get(7)?,
434 currency: row.get(8)?,
435 on_sale: row.get(9)?,
436 voucher: row.get(10)?,
437 added_at: row.get(11)?,
438 itad_id: row.get(12)?,
439 })
440 })?
441 .collect::<Result<Vec<_>, _>>()?;
442
443 Ok(games)
444}
445
446#[tauri::command]
448pub fn check_wishlist_status(state: State<AppState>, id: String) -> Result<bool, AppError> {
449 let conn = state.games_db.lock()?;
450
451 let count: i32 = conn
452 .query_row(
453 "SELECT COUNT(1) FROM wishlist WHERE id = ?1",
454 params![id],
455 |row| row.get(0),
456 )
457 .unwrap_or(0);
458
459 Ok(count > 0)
460}
461
462#[tauri::command]
464pub async fn refresh_prices(
465 _app: AppHandle,
466 state: State<'_, AppState>,
467) -> Result<String, AppError> {
468 let games_to_check: Vec<(String, String, Option<String>)> = {
470 let conn = state.games_db.lock()?;
471 let mut stmt = conn.prepare("SELECT id, name, itad_id FROM wishlist")?;
472 let rows = stmt.query_map([], |row| {
473 Ok((
474 row.get::<_, String>(0)?,
475 row.get::<_, String>(1)?,
476 row.get::<_, Option<String>>(2)?,
477 ))
478 })?;
479
480 rows.filter_map(|r| r.ok()).collect()
481 };
482
483 if games_to_check.is_empty() {
484 return Ok("Lista de desejos vazia.".to_string());
485 }
486
487 let mut itad_ids_to_fetch = Vec::new();
489 let mut game_map = std::collections::HashMap::new(); for (local_id, name, current_itad_id) in games_to_check {
492 let final_itad_id = match current_itad_id {
493 Some(id) if !id.is_empty() => {
494 id }
496 _ => {
497 match itad::find_game_id(&name).await {
499 Ok(found_id) => {
500 let conn = state.games_db.lock()?;
502 let _ = conn.execute(
503 "UPDATE wishlist SET itad_id = ?1 WHERE id = ?2",
504 params![&found_id, &local_id],
505 );
506 found_id
507 }
508 Err(e) => {
509 error!("Jogo '{}' não encontrado na ITAD: {}", name, e);
510 continue; }
512 }
513 }
514 };
515 itad_ids_to_fetch.push(final_itad_id.clone());
516 game_map.insert(final_itad_id, (local_id, name));
517 }
518
519 if itad_ids_to_fetch.is_empty() {
521 return Ok("Nenhum jogo correspondente encontrado na ITAD.".to_string());
522 }
523
524 let overviews = itad::get_prices(itad_ids_to_fetch)
525 .await
526 .map_err(AppError::NetworkError)?;
527
528 let mut updated_count = 0;
529
530 let conn = state.games_db.lock()?;
532
533 for game_data in overviews {
534 if let Some((local_id, _game_name)) = game_map.get(&game_data.id) {
535 if let Some(deal) = game_data.current {
537 let lowest = game_data.lowest.map(|l| l.price).unwrap_or(deal.price);
538
539 let cut = deal.cut.unwrap_or(0) as f64;
540 let normal_price = if cut > 0.0 {
541 deal.price / (1.0 - (cut / 100.0))
542 } else {
543 deal.price
544 };
545 match conn.execute(
546 "UPDATE wishlist SET
547 current_price = ?1,
548 currency = ?2,
549 lowest_price = ?3,
550 store_platform = ?4,
551 store_url = ?5,
552 on_sale = ?6,
553 normal_price = ?7,
554 voucher = ?8
555 WHERE id = ?9",
556 params![
557 deal.price,
558 deal.currency,
559 lowest,
560 deal.shop.name,
561 deal.url,
562 deal.cut > Some(0),
563 normal_price,
564 deal.voucher,
565 local_id
566 ],
567 ) {
568 Ok(_) => {
569 updated_count += 1;
570 }
571 Err(e) => error!("Erro ao salvar preço: {}", e),
572 }
573 }
574 } else {
575 error!("ITAD ID {} não encontrado no mapa local", game_data.id);
576 }
577 }
578
579 if updated_count > 0 {
580 info!("{} preços atualizados", updated_count);
581 }
582
583 Ok(format!("{} preços atualizados", updated_count))
584}