Skip to main content

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::{
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// Adaptador local para retorno de busca (compatível com frontend)
25#[derive(serde::Serialize)]
26pub struct SearchResult {
27    pub id: String,
28    pub name: String,
29    pub cover_url: Option<String>,
30}
31
32// === LÓGICA DE INSERÇÃO COMPARTILHADA ===
33
34/// Função auxiliar privada que contém o SQL de inserção.
35/// Aceita uma conexão (ou transação) já aberta.
36fn 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// === IMPORTAÇÃO POR ARQUIVOS EXTERNOS (Steam e ITAD) ===
63
64#[derive(Deserialize)]
65struct SteamExportRoot {
66    data: Vec<SteamExportItem>,
67}
68
69#[derive(Deserialize)]
70struct SteamExportItem {
71    title: String,
72    gameid: Vec<String>,        // ex: ["steam", "app/7520"]
73    price: Option<String>,      // ex: "R$ 73,99"
74    added_date: Option<String>, // ex: "26/12/2022"
75}
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, // UUID do ITAD
95    title: String,
96    added: i64, // Timestamp Unix
97}
98
99fn parse_steam_price(price_str: Option<&String>) -> Option<f64> {
100    // Remove "R$", espaços e troca vírgula por ponto
101    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        // Tenta DD/MM/YYYY
110        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
119/// Tenta processar o conteúdo como exportação da Steam
120fn 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        // Extrai ID da Steam ("app/7520" -> "7520")
126        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        // Steam Export não tem imagem direta, monta a URL padrão
136        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
161/// Tenta processar o conteúdo como exportação da ITAD (IsThereAnyDeal)
162fn 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            // Conversão de data Unix
169            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, // Usa o UUID da ITAD como ID
175                name: item.title,
176                cover_url: None, // ITAD export não tem capa, frontend deve mostrar placeholder
177                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/// Importa wishlist a partir de um arquivo JSON local (Steam ou ITAD)
194#[tauri::command]
195pub async fn import_wishlist(
196    state: State<'_, AppState>,
197    file_path: String,
198) -> Result<usize, AppError> {
199    // 1. Lê o arquivo
200    let content = fs::read_to_string(&file_path)?;
201
202    // 2. Tenta detectar o formato usando os parsers do steam_store e wishlist logic
203    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    // 3. Salva no banco
219    {
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/// Função para buscar capas faltantes na RAWG para jogos na Wishlist.
233#[tauri::command]
234pub async fn fetch_wishlist_covers(app: AppHandle) -> Result<(), AppError> {
235    // 1. Pega a API Key
236    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    // 2. Executa em background para não travar a interface
244    tauri::async_runtime::spawn(async move {
245        let state: State<AppState> = app.state();
246
247        // A. Busca quais jogos estão sem capa
248        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        // B. Itera e busca na RAWG
267        for (id, name) in missing_covers {
268            match rawg::search_games(&api_key, &name).await {
269                Ok(results) => {
270                    // Pega o primeiro resultado que tenha imagem
271                    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            // Respeita o limite da API (importante!)
292            sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
293        }
294
295        // C. Log resumido e avisa o frontend
296        if updated_count > 0 {
297            info!("{} capas atualizadas", updated_count);
298        }
299        let _ = app.emit("wishlist_updated", ());
300    });
301
302    Ok(())
303}
304
305// === GERENCIAMENTO DA WISHLIST (CRUD e Preços) ===
306
307/// Busca jogos na RAWG para adicionar à Wishlist.
308#[tauri::command]
309pub async fn search_wishlist_game(
310    app: AppHandle,
311    query: String,
312) -> Result<Vec<SearchResult>, AppError> {
313    // Usa RAWG para buscar o jogo e a capa
314    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/// Busca jogos por características/descrição via GameBrain para adicionar à Wishlist.
336///
337/// Diferente de `search_wishlist_game` (que busca pelo nome exato na RAWG),
338/// este comando aceita descrições livres como "medieval strategy games" ou
339/// "RPG cooperativo parecido com Skyrim" e retorna sugestões semânticas.
340///
341/// Retorna o mesmo `SearchResult` do comando RAWG para manter
342/// compatibilidade com o frontend existente.
343#[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/// Adiciona um jogo à lista de desejos.
372#[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/// Remove um jogo da lista de desejos.
406#[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/// Recupera todos os jogos da lista de desejos.
416#[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/// Verifica se um jogo está na lista de desejos.
447#[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/// Atualiza os preços de todos os jogos na Wishlist usando a API da ITAD.
463#[tauri::command]
464pub async fn refresh_prices(
465    _app: AppHandle,
466    state: State<'_, AppState>,
467) -> Result<String, AppError> {
468    // 1. Busca todos os jogos da Wishlist local
469    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    // 2. Resolve IDs da ITAD (Se faltar, busca na API e salva no banco)
488    let mut itad_ids_to_fetch = Vec::new();
489    let mut game_map = std::collections::HashMap::new(); // Mapa itad_id -> local_id
490
491    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 // Já tem ID, usa direto
495            }
496            _ => {
497                // Se não tem ID, busca na API (Lookup)
498                match itad::find_game_id(&name).await {
499                    Ok(found_id) => {
500                        // Salva no banco para cachear e não buscar na próxima vez
501                        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; // Jogo não achado na ITAD, pula
511                    }
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    // 3. Busca preços em lote
520    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    // 4. Atualiza o banco com os preços novos
531    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            // Pega a melhor oferta atual
536            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}