1use crate::constants::{CACHE_AMAZON_LUNA_TTL_DAYS, CACHE_DEFAULT_TTL_DAYS, CACHE_EA_PLAY_TTL_DAYS, CACHE_GAMEBRAIN_ID_TTL_DAYS, CACHE_GAMEBRAIN_MEDIA_TTL_DAYS, CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS, CACHE_GAMERPOWER_TTL_DAYS, CACHE_GAME_PASS_FULL_TTL_DAYS, CACHE_PROTON_DB_TTL_DAYS, CACHE_RAWG_GAME_TTL_DAYS, CACHE_RAWG_LIST_TTL_DAYS, CACHE_STEAM_PLAYTIME_TTL_DAYS, CACHE_STEAM_RESOLVE_TTL_DAYS, CACHE_STEAM_REVIEWS_TTL_DAYS, CACHE_STEAM_STORE_TTL_DAYS, CACHE_UBISOFT_PLUS_TTL_DAYS, NEXUS_CACHE_TTL_DAYS};
7use crate::errors::AppError;
8use crate::services::integration::nexus::NexusGame;
9use rusqlite::{params, Connection, OptionalExtension};
10use std::time::{SystemTime, UNIX_EPOCH};
11use tracing::{info, warn};
12
13#[derive(Debug, serde::Serialize)]
15pub struct CacheStats {
16 pub total_entries: i32,
17 pub rawg_entries: i32,
18 pub gamebrain_entries: i32,
19 pub steam_entries: i32,
20 pub expired_entries: i32,
21}
22
23fn current_timestamp() -> i64 {
25 SystemTime::now()
26 .duration_since(UNIX_EPOCH)
27 .unwrap()
28 .as_secs() as i64
29}
30
31pub fn initialize_cache_db(conn: &Connection) -> Result<(), String> {
35 conn.execute(
36 "CREATE TABLE IF NOT EXISTS api_cache (
37 source TEXT NOT NULL,
38 external_id TEXT NOT NULL,
39 payload TEXT NOT NULL,
40 updated_at INTEGER NOT NULL,
41 PRIMARY KEY (source, external_id)
42 )",
43 [],
44 )
45 .map_err(|e| format!("Erro ao criar tabela api_cache: {}", e))?;
46
47 conn.execute(
49 "CREATE INDEX IF NOT EXISTS idx_cache_updated
50 ON api_cache(source, updated_at)",
51 [],
52 )
53 .map_err(|e| format!("Erro ao criar índice: {}", e))?;
54
55 conn.execute(
57 "CREATE TABLE IF NOT EXISTS nexus_games (
58 domain_name TEXT PRIMARY KEY,
59 nexus_id INTEGER NOT NULL,
60 name TEXT NOT NULL,
61 genre TEXT,
62 approved_date INTEGER
63 )",
64 [],
65 )
66 .map_err(|e| format!("Erro ao criar tabela nexus_games: {}", e))?;
67
68 conn.execute(
69 "CREATE INDEX IF NOT EXISTS idx_nexus_games_name ON nexus_games(name)",
70 [],
71 )
72 .map_err(|e| format!("Erro ao criar índice: {}", e))?;
73
74 conn.execute(
75 "CREATE TABLE IF NOT EXISTS nexus_games_cache_meta (
76 id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
77 fetched_at INTEGER NOT NULL
78 )",
79 [],
80 )
81 .map_err(|e| format!("Erro ao criar tabela nexus_games_cache_meta: {}", e))?;
82
83 Ok(())
84}
85
86fn get_ttl_for_cache_type(cache_key: &str) -> i64 {
90 if cache_key.contains("_list_") {
92 CACHE_RAWG_LIST_TTL_DAYS * 24 * 60 * 60
93 } else if cache_key.starts_with("resolve_") {
94 CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60
95 } else if cache_key.starts_with("protondb_") {
96 CACHE_PROTON_DB_TTL_DAYS * 24 * 60 * 60
97 } else if cache_key.starts_with("gamebrain_id:") {
98 CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60
99 } else if cache_key.starts_with("gamebrain_similar:") {
100 CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60
101 } else if cache_key.starts_with("gamebrain_media:") {
102 CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60
103 } else if cache_key.starts_with("catalog_amazon_luna") {
104 CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60
105 } else if cache_key.starts_with("gamerpower_list_active") {
106 CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60
107 } else if cache_key.starts_with("catalog_game_pass_full") {
108 CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60
109 } else if cache_key.starts_with("catalog_ubisoft_plus") {
110 CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60
111 } else if cache_key.starts_with("catalog_ea_play") {
112 CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60
113 } else if cache_key.starts_with("rawg_") {
114 CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60
115 } else if cache_key.starts_with("store_") {
116 CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60
117 } else if cache_key.starts_with("reviews_") {
118 CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60
119 } else if cache_key.starts_with("playtime_") {
120 CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60
121 } else {
122 CACHE_DEFAULT_TTL_DAYS * 24 * 60 * 60 }
124}
125
126fn is_cache_expired(cache_key: &str, updated_at: i64) -> bool {
128 let now = current_timestamp();
129 let ttl_seconds = get_ttl_for_cache_type(cache_key);
130
131 (now - updated_at) > ttl_seconds
132}
133
134pub fn get_cached_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
140 let result: Result<(String, i64), rusqlite::Error> = conn.query_row(
141 "SELECT payload, updated_at FROM api_cache
142 WHERE source = ?1 AND external_id = ?2",
143 params![source, external_id],
144 |row| Ok((row.get(0)?, row.get(1)?)),
145 );
146
147 match result {
148 Ok((payload, updated_at)) => {
149 let full_key = external_id;
151 if is_cache_expired(full_key, updated_at) {
152 None
153 } else {
154 Some(payload)
155 }
156 }
157 Err(rusqlite::Error::QueryReturnedNoRows) => None,
158 Err(e) => {
159 warn!("Erro ao buscar cache: {}", e);
160 None
161 }
162 }
163}
164
165pub fn save_cached_api_data(
167 conn: &Connection,
168 source: &str,
169 external_id: &str,
170 payload: &str,
171) -> Result<(), String> {
172 let now = current_timestamp();
173
174 conn.execute(
175 "INSERT OR REPLACE INTO api_cache (source, external_id, payload, updated_at)
176 VALUES (?1, ?2, ?3, ?4)",
177 params![source, external_id, payload, now],
178 )
179 .map_err(|e| format!("Erro ao salvar cache: {}", e))?;
180
181 Ok(())
182}
183
184pub fn cleanup_expired_cache(conn: &Connection) -> Result<usize, String> {
186 let now = current_timestamp();
187
188 let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
190 let gamebrain_id_cutoff = now - (CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60);
191 let gamebrain_similar_cutoff = now - (CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60);
192 let gamebrain_media_cutoff = now - (CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60);
193 let amazon_luna_cutoff = now - (CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60);
194 let gamerpower_cutoff = now - (CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60);
195 let game_pass_full_cutoff = now - (CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60);
196 let ubisoft_plus_cutoff = now - (CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60);
197 let ea_play_cutoff = now - (CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60);
198 let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
199 let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
200 let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
201 let steam_resolve_cutoff = now - (CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60);
202 let protondb_cutoff = now - (CACHE_PROTON_DB_TTL_DAYS * 24 * 60 * 60);
203
204 let deleted = conn
205 .execute(
206 "DELETE FROM api_cache
207 WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
208 OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_id:%' AND updated_at < ?2)
209 OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_similar:%' AND updated_at < ?3)
210 OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_media:%' AND updated_at < ?4)
211 OR (source = 'amazon_luna' AND external_id LIKE 'catalog_amazon_luna%' AND updated_at < ?5)
212 OR (source = 'gamerpower' AND external_id LIKE 'gamerpower_list_active%' AND updated_at < ?6)
213 OR (source = 'game_pass_pc' AND external_id LIKE 'catalog_game_pass_full%' AND updated_at < ?7)
214 OR (source = 'ubisoft_plus' AND external_id LIKE 'catalog_ubisoft_plus%' AND updated_at < ?8)
215 OR (source = 'ea_play' AND external_id LIKE 'catalog_ea_play%' AND updated_at < ?9)
216 OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?10)
217 OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?11)
218 OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?12)
219 OR (source = 'steam_resolve' AND updated_at < ?13)
220 OR (source = 'protondb' AND updated_at < ?14)",
221 params![
222 rawg_cutoff,
223 gamebrain_id_cutoff,
224 gamebrain_similar_cutoff,
225 gamebrain_media_cutoff,
226 amazon_luna_cutoff,
227 gamerpower_cutoff,
228 game_pass_full_cutoff,
229 ubisoft_plus_cutoff,
230 ea_play_cutoff,
231 store_cutoff,
232 reviews_cutoff,
233 playtime_cutoff,
234 steam_resolve_cutoff,
235 protondb_cutoff,
236 ],
237 )
238 .map_err(|e| AppError::CacheCleanupError(e.to_string()).to_string())?;
239
240 if deleted > 0 {
241 info!("Cache cleanup: {} entradas removidas", deleted);
242 }
243
244 Ok(deleted)
245}
246
247pub fn get_stale_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
251 let result: Result<String, rusqlite::Error> = conn.query_row(
252 "SELECT payload FROM api_cache
253 WHERE source = ?1 AND external_id = ?2",
254 params![source, external_id],
255 |row| row.get(0),
256 );
257
258 result.ok() }
260
261pub fn get_cache_stats(conn: &Connection) -> Result<CacheStats, String> {
263 let total: i32 = conn
264 .query_row("SELECT COUNT(*) FROM api_cache", [], |row| row.get(0))
265 .unwrap_or(0);
266
267 let rawg: i32 = conn
268 .query_row(
269 "SELECT COUNT(*) FROM api_cache WHERE source = 'rawg'",
270 [],
271 |row| row.get(0),
272 )
273 .unwrap_or(0);
274
275 let gamebrain: i32 = conn
276 .query_row(
277 "SELECT COUNT(*) FROM api_cache WHERE source = 'gamebrain'",
278 [],
279 |row| row.get(0),
280 )
281 .unwrap_or(0);
282
283 let steam: i32 = conn
284 .query_row(
285 "SELECT COUNT(*) FROM api_cache WHERE source = 'steam'",
286 [],
287 |row| row.get(0),
288 )
289 .unwrap_or(0);
290
291 let now = current_timestamp();
292 let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
293 let gamebrain_id_cutoff = now - (CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60);
294 let gamebrain_similar_cutoff = now - (CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60);
295 let gamebrain_media_cutoff = now - (CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60);
296 let amazon_luna_cutoff = now - (CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60);
297 let gamerpower_cutoff = now - (CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60);
298 let game_pass_full_cutoff = now - (CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60);
299 let ubisoft_plus_cutoff = now - (CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60);
300 let ea_play_cutoff = now - (CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60);
301 let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
302 let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
303 let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
304 let steam_resolve_cutoff = now - (CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60);
305 let protondb_cutoff = now - (CACHE_PROTON_DB_TTL_DAYS * 24 * 60 * 60);
306
307 let expired: i32 = conn
308 .query_row(
309 "SELECT COUNT(*) FROM api_cache
310 WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
311 OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_id:%' AND updated_at < ?2)
312 OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_similar:%' AND updated_at < ?3)
313 OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_media:%' AND updated_at < ?4)
314 OR (source = 'amazon_luna' AND external_id LIKE 'catalog_amazon_luna%' AND updated_at < ?5)
315 OR (source = 'gamerpower' AND external_id LIKE 'gamerpower_list_active%' AND updated_at < ?6)
316 OR (source = 'game_pass_pc' AND external_id LIKE 'catalog_game_pass_full%' AND updated_at < ?7)
317 OR (source = 'ubisoft_plus' AND external_id LIKE 'catalog_ubisoft_plus%' AND updated_at < ?8)
318 OR (source = 'ea_play' AND external_id LIKE 'catalog_ea_play%' AND updated_at < ?9)
319 OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?10)
320 OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?11)
321 OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?12)
322 OR (source = 'steam_resolve' AND updated_at < ?13)
323 OR (source = 'protondb' AND updated_at < ?14)",
324 params![
325 rawg_cutoff,
326 gamebrain_id_cutoff,
327 gamebrain_similar_cutoff,
328 gamebrain_media_cutoff,
329 amazon_luna_cutoff,
330 gamerpower_cutoff,
331 game_pass_full_cutoff,
332 ubisoft_plus_cutoff,
333 ea_play_cutoff,
334 store_cutoff,
335 reviews_cutoff,
336 playtime_cutoff,
337 steam_resolve_cutoff,
338 protondb_cutoff,
339 ],
340 |row| row.get(0),
341 )
342 .unwrap_or(0);
343
344 Ok(CacheStats {
345 total_entries: total,
346 rawg_entries: rawg,
347 gamebrain_entries: gamebrain,
348 steam_entries: steam,
349 expired_entries: expired,
350 })
351}
352
353pub fn save_nexus_games_cache(
356 conn: &Connection,
357 games: &[NexusGame],
358) -> Result<(), rusqlite::Error> {
359 let tx = conn.unchecked_transaction()?;
360
361 tx.execute("DELETE FROM nexus_games", [])?;
362
363 {
364 let mut stmt = tx.prepare(
365 "INSERT OR REPLACE INTO nexus_games (domain_name, nexus_id, name, genre, approved_date)
366 VALUES (?1, ?2, ?3, ?4, ?5)",
367 )?;
368
369 for game in games {
370 stmt.execute(params![
371 game.domain_name,
372 game.id,
373 game.name,
374 game.genre,
375 game.approved_date,
376 ])?;
377 }
378 }
379
380 let now = chrono::Utc::now().timestamp();
381 tx.execute(
382 "INSERT OR REPLACE INTO nexus_games_cache_meta (id, fetched_at) VALUES (1, ?1)",
383 params![now],
384 )?;
385
386 tx.commit()
387}
388
389pub fn nexus_cache_is_stale(conn: &Connection) -> Result<bool, rusqlite::Error> {
390 let fetched_at: Option<i64> = conn
391 .query_row(
392 "SELECT fetched_at FROM nexus_games_cache_meta WHERE id = 1",
393 [],
394 |row| row.get(0),
395 )
396 .optional()?;
397
398 match fetched_at {
399 None => Ok(true),
400 Some(ts) => {
401 let now = chrono::Utc::now().timestamp();
402 Ok(now - ts > NEXUS_CACHE_TTL_DAYS * 24 * 60 * 60) }
404 }
405}
406
407pub fn get_cached_nexus_games(conn: &Connection) -> Result<Vec<NexusGame>, rusqlite::Error> {
409 let mut stmt = conn.prepare(
410 "SELECT nexus_id, name, domain_name, genre, approved_date FROM nexus_games",
411 )?;
412
413 let games = stmt
414 .query_map([], |row| {
415 Ok(NexusGame {
416 id: row.get(0)?,
417 name: row.get(1)?,
418 domain_name: row.get(2)?,
419 genre: row.get(3)?,
420 approved_date: row.get(4)?,
421 })
422 })?
423 .collect::<Result<Vec<_>, _>>()?;
424
425 Ok(games)
426}