import '../models/word_passport_model.dart'; import '../models/syncretic_session_model.dart'; import 'dart:io'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:shared_preferences/shared_preferences.dart'; import '../data/words_data.dart'; import '../models/history_entry_model.dart'; import '../models/word_model.dart'; import 'true_random_service.dart'; class WordsRepository { static final WordsRepository _instance = WordsRepository._internal(); factory WordsRepository() => _instance; WordsRepository._internal(); static const String _kHistoryKey = 'oracle_999_sessions_history_v2'; static const String _kMappingDateKey = 'oracle_daily_mapping_date_v1'; static const String _kMappingOrderKey = 'oracle_daily_mapping_order_v1'; static const String _kMappingSourceKey = 'oracle_daily_mapping_source_v1'; static const String _kCustomEditsKey = 'oracle_user_custom_edits_v1'; Map> _userEdits = {}; List _sessionWords = []; final List _sessions = []; Map> _kbData = {}; Map _passports = {}; WordPassport? getPassport(int wordId) => _passports[wordId]; Map get allPassports => Map.unmodifiable(_passports); bool _useRemoteShuffle = true; String? _dailyMappingDate; RandomSource _dailyMappingSource = RandomSource.local; List get allWords => List.unmodifiable(_sessionWords); List get sessions => List.unmodifiable(_sessions); List get history => sessions; // Alias for compatibility int get totalWords => _sessionWords.length; int get customEditsCount => _userEdits.length; Map> get allCustomEdits => Map.unmodifiable(_userEdits); String? get dailyMappingDate => _dailyMappingDate; RandomSource get dailyMappingSource => _dailyMappingSource; Future init({bool remoteDailyShuffle = true}) async { _useRemoteShuffle = remoteDailyShuffle; await _loadKbAsset(); await _loadPassportsAsset(); await _loadUserEditsFromPrefs(); await _restoreOrFetchDailyMapping(force: false); await _loadHistoryFromPrefs(); await _loadSyncreticSessionsFromPrefs(); } Future _loadPassportsAsset() async { for (final path in ['assets/data/oracle_passports.json', '../assets/data/oracle_passports.json']) { try { final file = File(path); if (file.existsSync()) { final jsonStr = file.readAsStringSync(); final decoded = json.decode(jsonStr) as Map; _passports = decoded.map((k, v) => MapEntry(int.tryParse(k) ?? 0, WordPassport.fromMap(v as Map))); return; } } catch (_) {} } if (Platform.environment.containsKey('FLUTTER_TEST')) { return; } try { final jsonStr = await rootBundle.loadString('assets/data/oracle_passports.json'); final decoded = json.decode(jsonStr) as Map; _passports = decoded.map((k, v) => MapEntry(int.tryParse(k) ?? 0, WordPassport.fromMap(v as Map))); } catch (_) {} } Future _loadKbAsset() async { // 1. In local dev/testing environments, load synchronously from filesystem for (final path in ['assets/data/oracle_kb.json', '../assets/data/oracle_kb.json']) { try { final file = File(path); if (file.existsSync()) { final jsonStr = file.readAsStringSync(); final decoded = json.decode(jsonStr) as Map; _kbData = decoded.map((k, v) => MapEntry(k.toLowerCase().trim(), v as Map)); return; } } catch (_) {} } if (Platform.environment.containsKey('FLUTTER_TEST')) { return; // Avoid hanging on rootBundle during headless tests } // 2. In packaged release app, load via rootBundle try { final jsonStr = await rootBundle.loadString('assets/data/oracle_kb.json'); final decoded = json.decode(jsonStr) as Map; _kbData = decoded.map((k, v) => MapEntry(k.toLowerCase().trim(), v as Map)); } catch (e) { debugPrint('Could not load oracle_kb.json asset: $e'); } } WordItem _enrichedWord(WordItem base) { final key = base.word.toLowerCase().trim(); var result = base; final kb = _kbData[key] ?? _kbData[base.id.toString()]; if (kb != null) { final kbWord = (kb['word'] as String?)?.toLowerCase().trim(); if (kbWord == null || kbWord == key) { result = result.enrichWithKb(kb); } } final edit = _userEdits[key]; if (edit != null) { result = result.copyWith( core: edit['core'] as String? ?? result.core, oracle: edit['oracle'] as String? ?? result.oracle, shadow: edit['shadow'] as String? ?? result.shadow, asks: edit['asks'] as String? ?? result.asks, step: edit['step'] as String? ?? result.step, cultures: edit.containsKey('cultures') ? _stringMap(edit['cultures']) : result.cultures, languages: edit.containsKey('languages') ? _stringMap(edit['languages']) : result.languages, layers: edit.containsKey('layers') ? _layerList(edit['layers']) : result.layers, binds: edit.containsKey('binds') ? _stringList(edit['binds']) : result.binds, isUserEdited: true, ); } return result; } Map _stringMap(dynamic raw) { if (raw is! Map) return {}; return raw.map((k, v) => MapEntry(k.toString(), v.toString())); } List _stringList(dynamic raw) { if (raw is! List) return []; return raw.map((e) => e.toString().trim()).where((e) => e.isNotEmpty).toList(); } List _layerList(dynamic raw) { if (raw is! List) return []; return [ for (final item in raw) if (item is Map) SymbolLayer( system: item['system']?.toString() ?? '', title: item['title']?.toString() ?? '', law: item['law']?.toString() ?? '', ), ].where((layer) => layer.system.isNotEmpty && layer.law.trim().isNotEmpty).toList(); } Map? customEditOf(String word) { final edit = _userEdits[word.toLowerCase().trim()]; if (edit == null) return null; return Map.from(edit); } void _applyEditsToSessionWords() { _sessionWords = _sessionWords.map((w) { final base = kRussianTop1000Words.firstWhere( (b) => b.id == w.id, orElse: () => w, ); return _enrichedWord(base).copyWith(sessionNumber: w.sessionNumber); }).toList(); } Future savePassport(WordPassport passport) async { _passports[passport.id] = passport; final key = passport.word.toLowerCase().trim(); final previous = Map.from(_userEdits[key] ?? {}); previous['passport'] = passport.toMap(); if (passport.semanticCore.isNotEmpty) previous['core'] = passport.semanticCore; if (passport.willVector.isNotEmpty) previous['asks'] = passport.willVector; if (passport.shadowWarning.isNotEmpty) previous['shadow'] = passport.shadowWarning; previous['updatedAt'] = DateTime.now().toUtc().toIso8601String(); _userEdits[key] = previous; await _saveUserEditsToPrefs(); } Future saveCustomEdit(String word, { required String core, required String oracle, required String shadow, required String asks, required String step, Map? cultures, List? layers, Map? languages, List? binds, Map? reports, }) async { final key = word.toLowerCase().trim(); final previous = Map.from(_userEdits[key] ?? {}); previous ..['core'] = core.trim() ..['oracle'] = oracle.trim() ..['shadow'] = shadow.trim() ..['asks'] = asks.trim() ..['step'] = step.trim() ..['updatedAt'] = DateTime.now().toUtc().toIso8601String(); if (cultures != null) previous['cultures'] = cultures; if (layers != null) { previous['layers'] = [ for (final layer in layers) if (layer.law.trim().isNotEmpty) {'system': layer.system, 'title': layer.title, 'law': layer.law.trim()}, ]; } if (languages != null) previous['languages'] = languages; if (binds != null) previous['binds'] = binds; if (reports != null) previous['reports'] = reports; _userEdits[key] = previous; await _saveUserEditsToPrefs(); _applyEditsToSessionWords(); } Future resetCustomEdit(String word) async { final key = word.toLowerCase().trim(); if (_userEdits.remove(key) != null) { await _saveUserEditsToPrefs(); _applyEditsToSessionWords(); } } Future clearAllCustomEdits() async { _userEdits.clear(); await _saveUserEditsToPrefs(); _applyEditsToSessionWords(); } Future importCustomEditsJson(String jsonStr) async { try { final decoded = json.decode(jsonStr); var count = 0; Map rawMap = {}; if (decoded is Map) { if (decoded['symbols'] is Map) { rawMap = Map.from(decoded['symbols'] as Map); } else if (decoded['symbols'] is List) { for (final item in decoded['symbols'] as List) { if (item is Map && item['word'] != null) { rawMap[item['word'].toString()] = Map.from(item); } } } else { rawMap = decoded; } } else if (decoded is List) { for (final item in decoded) { if (item is Map && item['word'] != null) { rawMap[item['word'].toString()] = Map.from(item); } } } for (final entry in rawMap.entries) { final key = entry.key.toLowerCase().trim(); if (entry.value is Map) { final val = Map.from(entry.value as Map); final core = val['core']?.toString().trim() ?? ''; final oracle = (val['oracle']?.toString() ?? val['description']?.toString() ?? '').trim(); final shadow = val['shadow']?.toString().trim() ?? ''; final asks = val['asks']?.toString().trim() ?? ''; final step = val['step']?.toString().trim() ?? ''; final cultures = val['cultures'] is Map ? _stringMap(val['cultures']) : null; final languages = val['languages'] is Map ? _stringMap(val['languages']) : null; final layers = val['layers'] is List ? _layerList(val['layers']) : null; final binds = val['binds'] is List ? _stringList(val['binds']) : null; final reports = val['reports'] is Map ? _stringMap(val['reports']) : null; final hasBody = core.isNotEmpty || oracle.isNotEmpty || shadow.isNotEmpty || asks.isNotEmpty || step.isNotEmpty || (cultures != null && cultures.isNotEmpty) || (languages != null && languages.isNotEmpty) || (layers != null && layers.isNotEmpty); if (hasBody) { _userEdits[key] = { 'core': core, 'oracle': oracle, 'shadow': shadow, 'asks': asks, 'step': step, 'cultures': ?cultures, 'languages': ?languages, if (layers != null) 'layers': [ for (final layer in layers) {'system': layer.system, 'title': layer.title, 'law': layer.law}, ], 'binds': ?binds, 'reports': ?reports, 'updatedAt': val['updatedAt']?.toString() ?? DateTime.now().toUtc().toIso8601String(), }; count++; } } } if (count > 0) { await _saveUserEditsToPrefs(); _applyEditsToSessionWords(); } return count; } catch (e) { debugPrint('Error importing custom edits: $e'); return 0; } } String exportCustomEditsJson() { final exportMap = {}; for (final entry in _userEdits.entries) { final key = entry.key; final wordItem = getWordByName(key); exportMap[key] = { 'word': key, if (wordItem != null) 'id': wordItem.id, if (wordItem != null) 'sessionNumber': wordItem.sessionNumber, if (wordItem != null) 'emoji': wordItem.emoji, if (wordItem != null) 'category': wordItem.category, if (wordItem != null) 'pos': wordItem.partOfSpeech, ...entry.value, }; } return const JsonEncoder.withIndent(' ').convert({ 'version': '1.0', 'type': 'oracle_custom_edits', 'exportedAt': DateTime.now().toUtc().toIso8601String(), 'count': exportMap.length, 'symbols': exportMap, }); } String exportFullLexiconJson() { final list = _sessionWords.map((w) { return { 'id': w.id, 'sessionNumber': w.sessionNumber, 'word': w.word, 'emoji': w.emoji, 'pos': w.partOfSpeech, 'category': w.category, 'core': w.core, 'oracle': w.oracle, 'shadow': w.shadow, 'asks': w.asks, 'step': w.step, 'binds': w.binds, 'cultures': w.cultures, 'languages': w.languages, 'layers': w.layers.map((l) => l.toMap()).toList(), 'description': w.description, 'family': w.family, 'operator': w.operator, 'valence': w.valence, 'jurisdictions': w.jurisdictions, 'isUserEdited': w.isUserEdited, }; }).toList(); return const JsonEncoder.withIndent(' ').convert({ 'version': '1.0', 'type': 'oracle_full_lexicon', 'exportedAt': DateTime.now().toUtc().toIso8601String(), 'count': list.length, 'symbols': list, }); } Future _loadUserEditsFromPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kCustomEditsKey); if (raw != null) { final map = json.decode(raw) as Map; _userEdits = map.map((k, v) => MapEntry(k.toLowerCase().trim(), Map.from(v as Map))); for (final entry in _userEdits.entries) { if (entry.value['passport'] is Map) { final p = WordPassport.fromMap(Map.from(entry.value['passport'] as Map)); _passports[p.id] = p; } } } } catch (_) {} } Future _saveUserEditsToPrefs() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kCustomEditsKey, json.encode(_userEdits)); } catch (_) {} } void _initCanonicalWords() { _sessionWords = [ for (var i = 0; i < kRussianTop1000Words.length; i++) _enrichedWord(kRussianTop1000Words[i]).copyWith(sessionNumber: i), ]; _applyEditsToSessionWords(); } Future reshuffleSessionNumbers() async { _initCanonicalWords(); await _healAllHistoryAndSessions(); return RandomSource.local; } Future _healAllHistoryAndSessions() async { await _loadHistoryFromPrefs(); await _loadSyncreticSessionsFromPrefs(); } Future _restoreOrFetchDailyMapping({required bool force}) async { final today = utcDateStamp(); _initCanonicalWords(); _dailyMappingDate = today; _dailyMappingSource = _useRemoteShuffle ? RandomSource.randomOrg : RandomSource.local; try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kMappingOrderKey); await prefs.setString(_kMappingDateKey, today); await prefs.setString(_kMappingSourceKey, _dailyMappingSource.name); } catch (_) {} return _dailyMappingSource; } // Compatibility helpers void applyIndexOrder(List order) => _initCanonicalWords(); Future loadPersistedMapping(String today) async => true; Future persistMapping(String date, List order, RandomSource source) async {} static String utcDateStamp([DateTime? now]) { final d = (now ?? DateTime.now()).toUtc(); final y = d.year.toString().padLeft(4, "0"); final m = d.month.toString().padLeft(2, "0"); final day = d.day.toString().padLeft(2, "0"); return "$y-$m-$day"; } Future deleteSession(String id) => removeSession(id); WordItem? getWordBySessionNumber(int number) { if (number < 0 || number >= _sessionWords.length) return null; return _sessionWords[number]; } WordItem? getWordByName(String name) { final query = name.trim().toLowerCase(); for (final w in _sessionWords) { if (w.word.toLowerCase() == query) return w; } return null; } WordItem? getWordById(int id) { for (final w in _sessionWords) { if (w.id == id) return w; } return null; } WordItem getRandomWord() { final random = Random.secure(); final idx = random.nextInt(_sessionWords.length); return _sessionWords[idx]; } WordItem getNextWord(int currentSessionNumber) { int nextNumber = (currentSessionNumber + 1) % _sessionWords.length; return _sessionWords[nextNumber]; } WordItem getPreviousWord(int currentSessionNumber) { int prevNumber = (currentSessionNumber - 1 + _sessionWords.length) % _sessionWords.length; return _sessionWords[prevNumber]; } List search(String query) { query = query.trim().toLowerCase(); if (query.isEmpty) return []; final number = int.tryParse(query); if (number != null && number >= 0 && number < _sessionWords.length) { return [_sessionWords[number]]; } final exact = []; final startsWith = []; final contains = []; for (final w in _sessionWords) { final lower = w.word.toLowerCase(); if (lower == query) { exact.add(w); } else if (lower.startsWith(query)) { startsWith.add(w); } else if (lower.contains(query) || w.description.toLowerCase().contains(query) || w.category.toLowerCase().contains(query) || w.partOfSpeech.toLowerCase().contains(query) || w.family.toLowerCase().contains(query) || w.operator.toLowerCase().contains(query) || w.core.toLowerCase().contains(query)) { contains.add(w); } } return [...exact, ...startsWith, ...contains]; } List getCategories() { final set = {'Все'}; for (final w in _sessionWords) { if (w.category.isNotEmpty) { set.add(w.category); } } return set.toList(); } List getPartsOfSpeech() { final set = {'Все'}; for (final w in _sessionWords) { if (w.partOfSpeech.isNotEmpty) { set.add(w.partOfSpeech); } } return set.toList(); } List getOperators() { final set = {'Все'}; for (final w in _sessionWords) { if (w.operator.isNotEmpty) { set.add(w.operator); } } return set.toList(); } // --- Session History Management --- Future saveSession(OracleSession session) async { final existingIdx = _sessions.indexWhere((s) => s.id == session.id); if (existingIdx != -1) { _sessions[existingIdx] = session; } else { _sessions.insert(0, session); } if (_sessions.length > 500) { _sessions.removeRange(500, _sessions.length); } await _saveHistoryToPrefs(); } // Backward compatibility alias Future addHistoryEntry(OracleSession entry) => saveSession(entry); Future updateSession(String id, {String? aiInterpretation, String? question}) async { final idx = _sessions.indexWhere((e) => e.id == id); if (idx != -1) { final old = _sessions[idx]; _sessions[idx] = old.copyWith( aiInterpretation: aiInterpretation ?? old.aiInterpretation, question: question ?? old.question, ); await _saveHistoryToPrefs(); } } Future updateHistoryEntry(String id, {String? aiInterpretation, String? question}) => updateSession(id, aiInterpretation: aiInterpretation, question: question); Future removeSession(String id) async { _sessions.removeWhere((s) => s.id == id); await _saveHistoryToPrefs(); } // Backward compatibility alias Future deleteHistoryEntry(String id) => removeSession(id); Future clearHistory() async { _sessions.clear(); await _saveHistoryToPrefs(); } Future _loadHistoryFromPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final historyJson = prefs.getStringList(_kHistoryKey); if (historyJson != null) { _sessions.clear(); bool needsResave = false; for (final itemStr in historyJson) { try { final map = json.decode(itemStr) as Map; final session = OracleSession.fromMap(map); // Heal session words to match their canonical sacred IDs final healedWords = session.words.map((sw) { final canonical = getWordByName(sw.word); if (canonical != null && canonical.sessionNumber != sw.sessionNumber) { needsResave = true; return SessionWord( word: canonical.word, sessionNumber: canonical.sessionNumber, partOfSpeech: canonical.partOfSpeech.isNotEmpty ? canonical.partOfSpeech : sw.partOfSpeech, category: canonical.category.isNotEmpty ? canonical.category : sw.category, symbolicMeaning: sw.symbolicMeaning.isNotEmpty ? sw.symbolicMeaning : canonical.displayMeaning, positionTitle: sw.positionTitle, ); } return sw; }).toList(); final healedMessages = session.messages.map((m) { if (m.attachedWord != null) { final canonical = getWordByName(m.attachedWord!.word); if (canonical != null && canonical.sessionNumber != m.attachedWord!.sessionNumber) { needsResave = true; return SessionChatMessage( id: m.id, sender: m.sender, text: m.text, timestamp: m.timestamp, attachedWord: SessionWord( word: canonical.word, sessionNumber: canonical.sessionNumber, partOfSpeech: canonical.partOfSpeech.isNotEmpty ? canonical.partOfSpeech : m.attachedWord!.partOfSpeech, category: canonical.category.isNotEmpty ? canonical.category : m.attachedWord!.category, symbolicMeaning: m.attachedWord!.symbolicMeaning, positionTitle: m.attachedWord!.positionTitle, ), ); } } return m; }).toList(); _sessions.add(session.copyWith( words: healedWords, messages: healedMessages, )); } catch (_) {} } if (needsResave) { await _saveHistoryToPrefs(); } } } catch (_) {} } Future _saveHistoryToPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final historyJson = _sessions.map((s) => json.encode(s.toMap())).toList(); await prefs.setStringList(_kHistoryKey, historyJson); } catch (_) {} } static const String _kSyncreticSessionsKey = 'oracle_999_syncretic_sessions_v1'; List _syncreticSessions = []; SyncreticSession? _currentSyncreticSession; List get syncreticSessions => List.unmodifiable(_syncreticSessions); SyncreticSession get currentSyncreticSession { if (_currentSyncreticSession == null) { final todayKey = DateTime.now().toIso8601String().substring(0, 10); final existing = _syncreticSessions.where((s) => s.dateKey == todayKey); if (existing.isNotEmpty) { _currentSyncreticSession = existing.first; } else { _currentSyncreticSession = SyncreticSession( id: 'syn_${DateTime.now().millisecondsSinceEpoch}', date: DateTime.now(), ); } } return _currentSyncreticSession!; } Future addReadingToSyncreticSession(SyncreticReading reading, {String? question}) async { final current = currentSyncreticSession; final existingIdx = current.readings.indexWhere((r) => r.id == reading.id); final List updatedReadings; if (existingIdx >= 0) { updatedReadings = [...current.readings]; updatedReadings[existingIdx] = reading; } else { updatedReadings = [...current.readings, reading]; } final updatedSession = current.copyWith( readings: updatedReadings, mainQuestion: (question != null && question.trim().isNotEmpty) ? question.trim() : current.mainQuestion, ); await saveSyncreticSession(updatedSession); } Future saveSyncreticSession(SyncreticSession session) async { _currentSyncreticSession = session; final idx = _syncreticSessions.indexWhere((s) => s.id == session.id); if (idx >= 0) { _syncreticSessions[idx] = session; } else { _syncreticSessions.insert(0, session); } await _saveSyncreticSessionsToPrefs(); } Future startNewSyncreticSession({String? question}) async { final newSession = SyncreticSession( id: 'syn_${DateTime.now().millisecondsSinceEpoch}', date: DateTime.now(), mainQuestion: question, ); await saveSyncreticSession(newSession); } Future _loadSyncreticSessionsFromPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final list = prefs.getStringList(_kSyncreticSessionsKey); if (list != null) { bool needsResave = false; _syncreticSessions = list .map((str) { try { final session = SyncreticSession.fromJson(str); final healedReadings = session.readings.map((r) { final healedItems = r.items.map((item) { if (item.linkedWord != null) { final canonical = getWordByName(item.linkedWord!.word); if (canonical != null && canonical.sessionNumber != item.linkedWord!.sessionNumber) { needsResave = true; var newTitle = item.title; final numRe = RegExp(r'#\d{3}'); if (numRe.hasMatch(newTitle)) { newTitle = newTitle.replaceFirst(numRe, '#${canonical.formattedSessionNumber}'); } return ReadingSignItem( title: newTitle, subtitle: item.subtitle, symbol: item.symbol, lore: item.lore, isReversed: item.isReversed, linkedWord: canonical, meta: item.meta, ); } } return item; }).toList(); return SyncreticReading( id: r.id, systemId: r.systemId, systemName: r.systemName, systemIcon: r.systemIcon, timestamp: r.timestamp, layoutTitle: r.layoutTitle, inputMethod: r.inputMethod, items: healedItems, note: r.note, question: r.question, ); }).toList(); return session.copyWith(readings: healedReadings); } catch (_) { return null; } }) .whereType() .toList(); if (needsResave) { await _saveSyncreticSessionsToPrefs(); } } } catch (e) { debugPrint('Error loading syncretic sessions: $e'); } } Future _saveSyncreticSessionsToPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final list = _syncreticSessions.map((s) => s.toJson()).toList(); await prefs.setStringList(_kSyncreticSessionsKey, list); } catch (e) { debugPrint('Error saving syncretic sessions: $e'); } } }