game_manager_lib\commands/
wishlist.rs

1//! Módulo de gerenciamento de lista de desejos (wishlist).
2//!
3//! Adaptado para v2.0 com integração IsThereAnyDeal.
4//! Centraliza a importação via arquivos JSON (Steam e ITAD).
5
6use crate::constants::RAWG_RATE_LIMIT_MS;
7use crate::database::{self, AppState};
8use crate::errors::AppError;
9use crate::models::WishlistGame;
10use crate::services::{itad, rawg};
11use chrono::NaiveDate;
12use rusqlite::{params, Connection};
13use serde::Deserialize;
14use std::fs;
15use std::time::Duration;
16use tauri::{AppHandle, Emitter, Manager, State};
17use tokio::time::sleep;
18use tracing::{error, info};
19
20// Adaptador local para retorno de busca (compatível com frontend)
21#[derive(serde::Serialize)]
22pub struct SearchResult {
23    pub id: String,
24    pub name: String,
25    pub cover_url: Option<String>,
26}
27
28// === LÓGICA DE INSERÇÃO COMPARTILHADA ===
29
30/// Função auxiliar privada que contém o SQL de inserção.
31/// Aceita uma conexão (ou transação) já aberta.
32fn insert_game_internal(conn: &Connection, game: &WishlistGame) -> Result<(), AppError> {
33    conn.execute(
34        "INSERT OR REPLACE INTO wishlist (
35            id, name, cover_url, store_url, store_platform,
36            current_price, normal_price, lowest_price,
37            currency, on_sale, voucher, itad_id, added_at
38        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
39        params![
40            game.id,
41            game.name,
42            game.cover_url,
43            game.store_url,
44            game.store_platform,
45            game.current_price,
46            game.normal_price,
47            game.lowest_price,
48            game.currency,
49            game.on_sale,
50            game.voucher,
51            game.itad_id,
52            game.added_at
53        ],
54    )?;
55    Ok(())
56}
57
58// === IMPORTAÇÃO POR ARQUIVOS EXTERNOS (Steam e ITAD) ===
59
60#[derive(Deserialize)]
61struct SteamExportRoot {
62    data: Vec<SteamExportItem>,
63}
64
65#[derive(Deserialize)]
66struct SteamExportItem {
67    title: String,
68    gameid: Vec<String>,        // ex: ["steam", "app/7520"]
69    price: Option<String>,      // ex: "R$ 73,99"
70    added_date: Option<String>, // ex: "26/12/2022"
71}
72
73#[derive(Deserialize)]
74struct ItadExportRoot {
75    data: ItadDataWrapper,
76}
77
78#[derive(Deserialize)]
79struct ItadDataWrapper {
80    data: Vec<ItadGroup>,
81}
82
83#[derive(Deserialize)]
84struct ItadGroup {
85    games: Vec<ItadGame>,
86}
87
88#[derive(Deserialize)]
89struct ItadGame {
90    id: String, // UUID do ITAD
91    title: String,
92    added: i64, // Timestamp Unix
93}
94
95fn parse_steam_price(price_str: Option<&String>) -> Option<f64> {
96    // Remove "R$", espaços e troca vírgula por ponto
97    price_str.as_ref().and_then(|s| {
98        let clean = s.replace("R$", "").replace(' ', "").replace(',', ".");
99        clean.parse::<f64>().ok()
100    })
101}
102
103fn parse_steam_date(date_str: Option<&String>) -> String {
104    if let Some(s) = date_str {
105        // Tenta DD/MM/YYYY
106        if let Ok(date) = NaiveDate::parse_from_str(s, "%d/%m/%Y") {
107            if let Some(datetime) = date.and_hms_opt(0, 0, 0) {
108                return datetime.and_utc().to_rfc3339();
109            }
110        }
111    }
112    chrono::Utc::now().to_rfc3339()
113}
114
115/// Tenta processar o conteúdo como exportação da Steam
116fn parse_steam_wishlist(content: &str) -> Option<Vec<WishlistGame>> {
117    let export: SteamExportRoot = serde_json::from_str(content).ok()?;
118    let mut games = Vec::new();
119
120    for item in export.data {
121        // Extrai ID da Steam ("app/7520" -> "7520")
122        let app_id = item
123            .gameid
124            .get(1)
125            .and_then(|s| s.strip_prefix("app/"))
126            .unwrap_or("0")
127            .to_string();
128
129        let price = parse_steam_price(item.price.as_ref());
130
131        // Steam Export não tem imagem direta, monta a URL padrão
132        let cover_url = format!(
133            "https://cdn.akamai.steamstatic.com/steam/apps/{}/header.jpg",
134            app_id
135        );
136
137        games.push(WishlistGame {
138            id: app_id.clone(),
139            name: item.title,
140            cover_url: Some(cover_url),
141            store_url: Some(format!("https://store.steampowered.com/app/{}", app_id)),
142            store_platform: Some("Steam".to_string()),
143            itad_id: None,
144            current_price: price,
145            normal_price: price,
146            lowest_price: price,
147            currency: Some("BRL".to_string()),
148            on_sale: false,
149            voucher: None,
150            added_at: Some(parse_steam_date(item.added_date.as_ref())),
151        });
152    }
153    Some(games)
154}
155
156/// Tenta processar o conteúdo como exportação da ITAD (IsThereAnyDeal)
157fn parse_itad_wishlist(content: &str) -> Option<Vec<WishlistGame>> {
158    let export: ItadExportRoot = serde_json::from_str(content).ok()?;
159    let mut games = Vec::new();
160
161    for group in export.data.data {
162        for item in group.games {
163            // Conversão de data Unix
164            let added_at = chrono::DateTime::from_timestamp(item.added, 0)
165                .map(|dt| dt.to_rfc3339())
166                .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
167
168            games.push(WishlistGame {
169                id: item.id, // Usa o UUID da ITAD como ID
170                name: item.title,
171                cover_url: None, // ITAD export não tem capa, frontend deve mostrar placeholder
172                store_url: None,
173                store_platform: Some("ITAD".to_string()),
174                itad_id: None,
175                current_price: None,
176                normal_price: None,
177                lowest_price: None,
178                currency: Some("BRL".to_string()),
179                on_sale: false,
180                voucher: None,
181                added_at: Some(added_at),
182            });
183        }
184    }
185    Some(games)
186}
187
188/// Importa wishlist a partir de um arquivo JSON local (Steam ou ITAD)
189#[tauri::command]
190pub async fn import_wishlist(
191    state: State<'_, AppState>,
192    file_path: String,
193) -> Result<usize, AppError> {
194    // 1. Lê o arquivo
195    let content = fs::read_to_string(&file_path)?;
196
197    // 2. Tenta detectar o formato usando os parsers do steam.rs e wishlist logic
198    let games = if let Some(steam_games) = parse_steam_wishlist(&content) {
199        steam_games
200    } else if let Some(itad_games) = parse_itad_wishlist(&content) {
201        itad_games
202    } else {
203        return Err(AppError::ValidationError(
204            "Formato de arquivo não reconhecido.".to_string(),
205        ));
206    };
207
208    let total = games.len();
209    if total == 0 {
210        return Ok(0);
211    }
212
213    // 3. Salva no banco
214    {
215        let mut conn = state.library_db.lock()?;
216        let tx = conn.transaction()?;
217
218        for game in games {
219            insert_game_internal(&tx, &game)?;
220        }
221        tx.commit()?;
222    }
223
224    Ok(total)
225}
226
227/// Função para buscar capas faltantes na RAWG para jogos na Wishlist.
228#[tauri::command]
229pub async fn fetch_wishlist_covers(app: AppHandle) -> Result<(), AppError> {
230    // 1. Pega a API Key
231    let api_key = database::get_secret(&app, "rawg_api_key")?;
232    if api_key.is_empty() {
233        return Err(AppError::ValidationError(
234            "API Key da RAWG não configurada.".to_string(),
235        ));
236    }
237
238    // 2. Executa em background para não travar a interface
239    tauri::async_runtime::spawn(async move {
240        let state: State<AppState> = app.state();
241
242        // A. Busca quais jogos estão sem capa
243        let missing_covers: Vec<(String, String)> = {
244            let conn = state.library_db.lock().unwrap();
245            let mut stmt = conn
246                .prepare("SELECT id, name FROM wishlist WHERE cover_url IS NULL OR cover_url = ''")
247                .unwrap();
248
249            stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
250                .unwrap()
251                .flatten()
252                .collect()
253        };
254
255        if missing_covers.is_empty() {
256            return;
257        }
258
259        let mut updated_count = 0;
260
261        // B. Itera e busca na RAWG
262        for (id, name) in missing_covers {
263            match rawg::search_games(&api_key, &name).await {
264                Ok(results) => {
265                    // Pega o primeiro resultado que tenha imagem
266                    if let Some(first_match) = results.iter().find(|g| g.background_image.is_some())
267                    {
268                        if let Some(cover) = &first_match.background_image {
269                            if let Ok(conn) = state.library_db.lock() {
270                                if conn
271                                    .execute(
272                                        "UPDATE wishlist SET cover_url = ?1 WHERE id = ?2",
273                                        params![cover, id],
274                                    )
275                                    .is_ok()
276                                {
277                                    updated_count += 1;
278                                }
279                            }
280                        }
281                    }
282                }
283                Err(e) => error!("Erro RAWG para '{}': {}", name, e),
284            }
285
286            // Respeita o limite da API (importante!)
287            sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
288        }
289
290        // C. Log resumido e avisa o frontend
291        if updated_count > 0 {
292            info!("{} capas atualizadas", updated_count);
293        }
294        let _ = app.emit("wishlist_updated", ());
295    });
296
297    Ok(())
298}
299
300// === GERENCIAMENTO DA WISHLIST (CRUD e Preços) ===
301
302/// Busca jogos na RAWG para adicionar à Wishlist.
303#[tauri::command]
304pub async fn search_wishlist_game(
305    app: AppHandle,
306    query: String,
307) -> Result<Vec<SearchResult>, AppError> {
308    // Usa RAWG para buscar o jogo e a capa
309    let api_key = database::get_secret(&app, "rawg_api_key")?;
310    if api_key.is_empty() {
311        return Err(AppError::ValidationError(
312            "Configure a chave da RAWG nas configurações.".to_string(),
313        ));
314    }
315
316    let results = rawg::search_games(&api_key, &query)
317        .await
318        .map_err(AppError::NetworkError)?;
319
320    Ok(results
321        .into_iter()
322        .map(|g| SearchResult {
323            id: g.id.to_string(),
324            name: g.name,
325            cover_url: g.background_image,
326        })
327        .collect())
328}
329
330/// Adiciona um jogo à lista de desejos.
331#[tauri::command]
332pub fn add_to_wishlist(
333    state: State<AppState>,
334    id: String,
335    name: String,
336    cover_url: Option<String>,
337    store_url: Option<String>,
338    current_price: Option<f64>,
339    itad_id: Option<String>,
340) -> Result<String, AppError> {
341    let game = WishlistGame {
342        id,
343        name,
344        cover_url,
345        store_url,
346        store_platform: None,
347        itad_id,
348        current_price,
349        normal_price: current_price,
350        lowest_price: current_price,
351        currency: Some("BRL".to_string()),
352        on_sale: false,
353        voucher: None,
354        added_at: Some(chrono::Utc::now().to_rfc3339()),
355    };
356
357    let conn = state.library_db.lock()?;
358
359    insert_game_internal(&conn, &game)?;
360
361    Ok("Adicionado à Wishlist!".to_string())
362}
363
364/// Remove um jogo da lista de desejos.
365#[tauri::command]
366pub fn remove_from_wishlist(state: State<AppState>, id: String) -> Result<String, AppError> {
367    let conn = state.library_db.lock()?;
368
369    conn.execute("DELETE FROM wishlist WHERE id = ?1", params![id])?;
370
371    Ok("Jogo removido da lista de desejos.".to_string())
372}
373
374/// Recupera todos os jogos da lista de desejos.
375#[tauri::command]
376pub fn get_wishlist(state: State<AppState>) -> Result<Vec<WishlistGame>, AppError> {
377    let conn = state.library_db.lock()?;
378
379    let mut stmt = conn
380        .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")?;
381
382    let games = stmt
383        .query_map([], |row| {
384            Ok(WishlistGame {
385                id: row.get(0)?,
386                name: row.get(1)?,
387                cover_url: row.get(2)?,
388                store_url: row.get(3)?,
389                store_platform: row.get(4)?,
390                current_price: row.get(5)?,
391                normal_price: row.get(6)?,
392                lowest_price: row.get(7)?,
393                currency: row.get(8)?,
394                on_sale: row.get(9)?,
395                voucher: row.get(10)?,
396                added_at: row.get(11)?,
397                itad_id: row.get(12)?,
398            })
399        })?
400        .collect::<Result<Vec<_>, _>>()?;
401
402    Ok(games)
403}
404
405/// Verifica se um jogo está na lista de desejos.
406#[tauri::command]
407pub fn check_wishlist_status(state: State<AppState>, id: String) -> Result<bool, AppError> {
408    let conn = state.library_db.lock()?;
409
410    let count: i32 = conn
411        .query_row(
412            "SELECT COUNT(1) FROM wishlist WHERE id = ?1",
413            params![id],
414            |row| row.get(0),
415        )
416        .unwrap_or(0);
417
418    Ok(count > 0)
419}
420
421/// Atualiza os preços de todos os jogos na Wishlist usando a API da ITAD.
422#[tauri::command]
423pub async fn refresh_prices(
424    _app: AppHandle,
425    state: State<'_, AppState>,
426) -> Result<String, AppError> {
427    // 1. Busca todos os jogos da Wishlist local
428    let games_to_check: Vec<(String, String, Option<String>)> = {
429        let conn = state.library_db.lock()?;
430        let mut stmt = conn.prepare("SELECT id, name, itad_id FROM wishlist")?;
431        let rows = stmt.query_map([], |row| {
432            Ok((
433                row.get::<_, String>(0)?,
434                row.get::<_, String>(1)?,
435                row.get::<_, Option<String>>(2)?,
436            ))
437        })?;
438
439        rows.filter_map(|r| r.ok()).collect()
440    };
441
442    if games_to_check.is_empty() {
443        return Ok("Lista de desejos vazia.".to_string());
444    }
445
446    // 2. Resolve IDs da ITAD (Se faltar, busca na API e salva no banco)
447    let mut itad_ids_to_fetch = Vec::new();
448    let mut game_map = std::collections::HashMap::new(); // Mapa itad_id -> local_id
449
450    for (local_id, name, current_itad_id) in games_to_check {
451        let final_itad_id = match current_itad_id {
452            Some(id) if !id.is_empty() => {
453                id // Já tem ID, usa direto
454            }
455            _ => {
456                // Se não tem ID, busca na API (Lookup)
457                match itad::find_game_id(&name).await {
458                    Ok(found_id) => {
459                        // Salva no banco para cachear e não buscar na próxima vez
460                        let conn = state.library_db.lock()?;
461                        let _ = conn.execute(
462                            "UPDATE wishlist SET itad_id = ?1 WHERE id = ?2",
463                            params![&found_id, &local_id],
464                        );
465                        found_id
466                    }
467                    Err(e) => {
468                        error!("Jogo '{}' não encontrado na ITAD: {}", name, e);
469                        continue; // Jogo não achado na ITAD, pula
470                    }
471                }
472            }
473        };
474        itad_ids_to_fetch.push(final_itad_id.clone());
475        game_map.insert(final_itad_id, (local_id, name));
476    }
477
478    // 3. Busca preços em lote
479    if itad_ids_to_fetch.is_empty() {
480        return Ok("Nenhum jogo correspondente encontrado na ITAD.".to_string());
481    }
482
483    let overviews = itad::get_prices(itad_ids_to_fetch)
484        .await
485        .map_err(AppError::NetworkError)?;
486
487    let mut updated_count = 0;
488
489    // 4. Atualiza o banco com os preços novos
490    let conn = state.library_db.lock()?;
491
492    for game_data in overviews {
493        if let Some((local_id, _game_name)) = game_map.get(&game_data.id) {
494            // Pega a melhor oferta atual
495            if let Some(deal) = game_data.current {
496                let lowest = game_data.lowest.map(|l| l.price).unwrap_or(deal.price);
497
498                let cut = deal.cut.unwrap_or(0) as f64;
499                let normal_price = if cut > 0.0 {
500                    deal.price / (1.0 - (cut / 100.0))
501                } else {
502                    deal.price
503                };
504                match conn.execute(
505                    "UPDATE wishlist SET
506                        current_price = ?1,
507                        currency = ?2,
508                        lowest_price = ?3,
509                        store_platform = ?4,
510                        store_url = ?5,
511                        on_sale = ?6,
512                        normal_price = ?7,
513                        voucher = ?8
514                     WHERE id = ?9",
515                    params![
516                        deal.price,
517                        deal.currency,
518                        lowest,
519                        deal.shop.name,
520                        deal.url,
521                        deal.cut > Some(0),
522                        normal_price,
523                        deal.voucher,
524                        local_id
525                    ],
526                ) {
527                    Ok(_) => {
528                        updated_count += 1;
529                    }
530                    Err(e) => error!("Erro ao salvar preço: {}", e),
531                }
532            }
533        } else {
534            error!("ITAD ID {} não encontrado no mapa local", game_data.id);
535        }
536    }
537
538    if updated_count > 0 {
539        info!("{} preços atualizados", updated_count);
540    }
541
542    Ok(format!("{} preços atualizados", updated_count))
543}