import 'dart:io'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:flutter/foundation.dart'; import 'gemini_service.dart'; /// 画像ハッシュベースのAI解析キャッシュサービス /// /// 目的: /// - 同じ日本酒を複数回撮影してもAPI呼び出しは1回のみ /// - Gemini API制限(1日20回)の節約 /// /// 仕組み: /// 1. 画像のSHA-256ハッシュを計算 /// 2. Hiveに{hash: 解析結果}を保存 /// 3. 同じハッシュの画像は即座にキャッシュから返す class AnalysisCacheService { static const String _cacheBoxName = 'analysis_cache_v1'; static const String _brandIndexBoxName = 'brand_index_v1'; // 銘柄名→ハッシュのインデックス static Box? _box; static Box? _brandIndexBox; /// キャッシュボックスの初期化 static Future init() async { if (_box != null && _brandIndexBox != null) return; // 既に初期化済み try { _box = await Hive.openBox(_cacheBoxName); _brandIndexBox = await Hive.openBox(_brandIndexBoxName); debugPrint('Analysis Cache initialized (${_box!.length} entries, ${_brandIndexBox!.length} brands)'); } catch (e) { debugPrint('Failed to open cache box: $e'); } } /// 画像のSHA-256ハッシュを計算 /// /// 同じ写真は同じハッシュになる(ビット完全一致) /// 例: "abc123..." (64文字の16進数文字列) static Future computeImageHash(String imagePath) async { try { final bytes = await File(imagePath).readAsBytes(); final digest = sha256.convert(bytes); return digest.toString(); } catch (e) { debugPrint('Hash computation failed: $e'); // ハッシュ計算失敗時はパスをそのまま使用(キャッシュなし) return imagePath; } } /// 複数画像の結合ハッシュを計算 /// /// 複数枚の写真を組み合わせた場合、順序も考慮してハッシュ化 /// 例: ["image1.jpg", "image2.jpg"] → "combined_hash_abc123..." static Future computeCombinedHash(List imagePaths) async { if (imagePaths.isEmpty) return ''; if (imagePaths.length == 1) return computeImageHash(imagePaths.first); // 各画像のハッシュを連結してから再ハッシュ final hashes = await Future.wait( imagePaths.map((path) => computeImageHash(path)), ); final combined = hashes.join('_'); return sha256.convert(utf8.encode(combined)).toString(); } /// キャッシュから取得 /// /// 戻り値: キャッシュがあれば解析結果、なければ null /// タイムスタンプ付き新形式と旧形式(直接 JSON)の両方に対応 static Future getCached(String imageHash) async { await init(); if (_box == null) return null; try { final jsonString = _box!.get(imageHash); if (jsonString == null) { debugPrint('Cache MISS: $imageHash'); return null; } final decoded = jsonDecode(jsonString) as Map; // 新形式: { data: {...}, savedAt: "..." } if (decoded.containsKey('data') && decoded.containsKey('savedAt')) { debugPrint('Cache HIT: ${decoded['data']['name'] ?? 'Unknown'}'); return SakeAnalysisResult.fromJson(decoded['data'] as Map); } // 旧形式(後方互換): 直接 SakeAnalysisResult の JSON debugPrint('Cache HIT (legacy): ${decoded['name'] ?? 'Unknown'}'); return SakeAnalysisResult.fromJson(decoded); } catch (e) { debugPrint('Cache read error: $e'); return null; } } /// キャッシュに保存 /// /// 解析結果を JSON 化してタイムスタンプ付きで Hive に永続化 static Future saveCache(String imageHash, SakeAnalysisResult result) async { await init(); if (_box == null) return; try { final entry = { 'data': result.toJson(), 'savedAt': DateTime.now().toIso8601String(), }; await _box!.put(imageHash, jsonEncode(entry)); debugPrint('Cache saved: ${result.name ?? 'Unknown'}'); } catch (e) { debugPrint('Cache save error: $e'); } } /// キャッシュをクリア(デバッグ用) static Future clearAll() async { await init(); if (_box == null) return; final count = _box!.length; await _box!.clear(); debugPrint('Cache cleared ($count entries deleted)'); } /// キャッシュサイズを取得(統計用) static Future getCacheSize() async { await init(); return _box?.length ?? 0; } /// 30日以上経過したキャッシュエントリを削除 /// /// アプリ起動時またはバックグラウンドで呼び出す。 /// 旧形式エントリ(タイムスタンプなし)は対象外として保持する。 /// _brandIndexBox の孤立エントリ(削除されたハッシュを参照するもの)も合わせて削除する。 static Future cleanupExpired({int ttlDays = 30}) async { await init(); if (_box == null) return; final cutoff = DateTime.now().subtract(Duration(days: ttlDays)); final keysToDelete = []; for (final key in _box!.keys) { try { final jsonString = _box!.get(key as String); if (jsonString == null) continue; final decoded = jsonDecode(jsonString) as Map; if (!decoded.containsKey('savedAt')) continue; // 旧形式は対象外 final savedAt = DateTime.tryParse(decoded['savedAt'] as String? ?? ''); if (savedAt != null && savedAt.isBefore(cutoff)) { keysToDelete.add(key); } } catch (_) { // 読み込みエラーのエントリは無視 } } if (keysToDelete.isNotEmpty) { await _box!.deleteAll(keysToDelete); debugPrint('Cache cleanup: ${keysToDelete.length} expired entries removed'); // 削除したハッシュを参照している _brandIndexBox の孤立エントリも削除する if (_brandIndexBox != null) { final deletedHashes = keysToDelete.toSet(); final brandKeysToDelete = []; for (final brandKey in _brandIndexBox!.keys) { final hash = _brandIndexBox!.get(brandKey as String); if (hash != null && deletedHashes.contains(hash)) { brandKeysToDelete.add(brandKey); } } if (brandKeysToDelete.isNotEmpty) { await _brandIndexBox!.deleteAll(brandKeysToDelete); debugPrint('Cache cleanup: ${brandKeysToDelete.length} orphaned brand index entries removed'); } } } } // ============================================ // 銘柄名ベースキャッシュ機能(v1.0.15追加) // ============================================ /// 銘柄名でキャッシュを検索 /// /// 異なる写真でも同じ銘柄なら、以前の解析結果を返す /// これによりチャート(tasteStats)の一貫性を保証 static Future getCachedByBrand(String? brandName) async { if (brandName == null || brandName.isEmpty) return null; await init(); if (_brandIndexBox == null) return null; try { final normalized = _normalizeBrandName(brandName); final imageHash = _brandIndexBox!.get(normalized); if (imageHash == null) { debugPrint('Brand Index MISS: $brandName'); return null; } debugPrint('Brand Index HIT: $brandName -> $imageHash'); return getCached(imageHash); } catch (e) { debugPrint('Brand index lookup error: $e'); return null; } } /// 銘柄名をインデックスに登録 /// /// [forceUpdate] = true のとき(再解析など)は既存エントリを上書きする。 /// false のときは最初の結果を優先し、上書きしない。 static Future registerBrandIndex( String? brandName, String imageHash, { bool forceUpdate = false, }) async { if (brandName == null || brandName.isEmpty) return; await init(); if (_brandIndexBox == null) return; try { final normalized = _normalizeBrandName(brandName); final exists = _brandIndexBox!.containsKey(normalized); if (!exists || forceUpdate) { await _brandIndexBox!.put(normalized, imageHash); debugPrint('Brand index ${exists ? "updated" : "registered"}: $brandName'); } else { debugPrint('Brand index already exists: $brandName (skipped)'); } } catch (e) { debugPrint('Brand index registration error: $e'); } } /// 銘柄名の正規化 /// /// 全角・半角スペースを除去し、ASCII文字を小文字に統一する。 /// 例: "獺祭 純米大吟醸" → "獺祭純米大吟醸" /// 例: "DASSAI" → "dassai" /// 日本語文字には大文字・小文字の区別がないため、.toLowerCase() は ASCII のみに作用する。 static String _normalizeBrandName(String name) { return name .replaceAll(RegExp(r'\s+'), '') // 全角・半角スペース除去 .toLowerCase(); // ASCII ローマ字の表記ゆれを吸収(日本語には無効) } /// 銘柄名インデックスをクリア(デバッグ用) static Future clearBrandIndex() async { await init(); if (_brandIndexBox == null) return; final count = _brandIndexBox!.length; await _brandIndexBox!.clear(); debugPrint('Brand index cleared ($count entries deleted)'); } }