import 'dart:convert'; import 'dart:math'; import 'package:injectable/injectable.dart'; import 'package:drift_postgres/drift_postgres.dart'; import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/packs/card_image_storage.dart'; import 'package:mnemo_cards_backend/packs/voice_storage.dart'; import 'package:mnemo_cards_backend/storage/minio_config.dart'; import 'package:mnemo_cards_backend/storage/minio_service.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:drift/drift.dart' as drift; import 'generators/pack_test_generator.dart'; @lazySingleton class TestManager { final AppDatabase _db; final MinioService _minioService; TestManager(this._db, this._minioService); static final _uuidRegex = RegExp( r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false, ); bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim()); bool _isBase64OrDataUrlImage(String value) => CardImageStorage.tryParseBase64Image(value) != null; String? _extractCardIdFromApiImageUrl(String value) { final v = value.trim(); if (!CardImageStorage.isApiImageUrl(v)) return null; final match = RegExp(r'/cards/([^/]+)/image(?:Back)?$').firstMatch(v)?.group(1); if (match == null) return null; return _isUuid(match) ? match : null; } Future _tryLinkCardToPack({ required String packId, required String cardId, }) async { try { final card = await _db.packDao.getCardById(cardId); if (card == null) return; await _db.packDao.addCardToPack(packId: packId, cardId: cardId); } catch (_) { // Best-effort only. } } Future _convertBase64ToCard(String base64Image, String? packId) async { try { final companion = GameCardsCompanion.insert( original: 'test_image', translation: 'test_image', image: '', mnemo: const drift.Value('test_image'), ); final cardId = await _db.packDao.createCard(companion); if (packId != null) { await _tryLinkCardToPack(packId: packId, cardId: cardId); } final stored = await CardImageStorage.persistFromBase64( cardId: cardId, imageValue: base64Image, preferredFileName: null, isBack: false, ); if (stored == null) return null; final created = await _db.packDao.getCardById(cardId); if (created == null) return null; await _db.packDao.updateCard( created.copyWith( image: stored.fileName, updatedAt: PgDateTime(DateTime.now()), ), ); return cardId; } catch (_) { return null; } } Future _normalizeImageValueForDb( String? value, { required String? packId, }) async { if (value == null) return null; final v = value.trim(); if (v.isEmpty) return null; // Remote URL: keep as is if (CardImageStorage.isRemoteUrl(v)) return v; // If it's already a valid UUID (object ID in MinIO), keep it if (_isUuid(v)) { return v; } // API URL: extract cardId if it's a valid UUID final fromApi = _extractCardIdFromApiImageUrl(v); if (fromApi != null && _isUuid(fromApi)) { return fromApi; // Return as object ID } // Base64/data-url: upload to MinIO and return object ID if (_isBase64OrDataUrlImage(v)) { final parsed = CardImageStorage.tryParseBase64Image(v); if (parsed != null) { try { final objectId = await _minioService.uploadFile( bucket: MinioConfig.cardImagesBucket, bytes: parsed.bytes, contentType: parsed.contentType, ); return objectId; } catch (e) { print('Error uploading test image to MinIO: $e'); // Fallback: try old method for backward compatibility return _convertBase64ToCard(v, packId); } } } // Legacy: old filename format or other - keep as is for backward compatibility return v; } /// Converts image value to presigned URL for display Future _imageValueToApiUrl( String? value, { required String? packId, }) async { if (value == null) return null; final v = value.trim(); if (v.isEmpty) return null; // Already a URL: return as is if (CardImageStorage.isRemoteUrl(v) || CardImageStorage.isApiImageUrl(v)) { return v; } // If it's a valid UUID (object ID in MinIO), generate presigned URL if (_isUuid(v)) { final presignedUrl = await _minioService.getPresignedUrl( bucket: MinioConfig.cardImagesBucket, objectId: v, ); return presignedUrl; } // Legacy: try to extract cardId from API URL final cardId = _extractCardIdFromApiImageUrl(v); if (cardId != null && packId != null) { // If cardId is UUID, generate presigned URL if (_isUuid(cardId)) { final presignedUrl = await _minioService.getPresignedUrl( bucket: MinioConfig.cardImagesBucket, objectId: cardId, ); return presignedUrl; } // Old format: use API endpoint return '/api/v2/packs/$packId/cards/$cardId/image'; } // Don't leak base64 through user API. if (_isBase64OrDataUrlImage(v)) return null; return v; } /// Converts an audio value to a presigned URL if it's a UUID (objectId in MinIO), /// or returns it as-is if it's already a remote URL. /// /// Similar to _imageValueToApiUrl but for audio files stored in voice-audio bucket. Future _audioValueToApiUrl(String? value) async { if (value == null) return null; final v = value.trim(); if (v.isEmpty) return null; // Already a remote URL: return as is if (VoiceStorage.isRemoteUrl(v)) { return v; } // If it's a valid UUID (object ID in MinIO), generate presigned URL if (_isUuid(v)) { final presignedUrl = await _minioService.getPresignedUrl( bucket: MinioConfig.voiceAudioBucket, objectId: v, ); return presignedUrl; } // Legacy formats (base64, filenames) - don't leak through API // Return null to indicate invalid/unsupported format if (VoiceStorage.tryParseBase64Audio(v) != null) return null; // For other formats (like legacy filenames), return null // They should be migrated to UUID format return null; } Future _testStatisticsDto( String userId, String testId) async { final statistics = await _db.testDao.getTestStatistics(userId, testId); if (statistics == null) return null; // Convert TestStatistic to TestStatisticsDto // metadata is stored as JSON string with 'attempts' key final metadataMap = statistics.metadata.isNotEmpty ? (json.decode(statistics.metadata) as Map? ?? {}) : {}; final attempts = (metadataMap['attempts'] as List?) ?? []; // Get words from the last attempt, or empty list if no attempts final lastAttempt = attempts.isNotEmpty ? attempts.last as Map? : null; final wordsJson = (lastAttempt?['words'] as List?) ?? []; // Convert words JSON to WordStatisticsDto final words = wordsJson .map((w) => WordStatisticsDto.fromJson(w as Map)) .toList(); return TestStatisticsDto( testId: statistics.testId, words: AllWordsStatisticsDto(words: words), attempts: attempts.length, sessionToken: lastAttempt?['sessionToken'] as String?, ); } Future fetchTest(String id, UserModel user) async { final testId = id; final test = await _db.testDao.getTestById(testId); if (test == null) return null; // Get packId for the test to convert image IDs to URLs final packId = await _db.testDao.getPackIdForTest(testId); final questions = await _db.testDao.getTestQuestions(testId); final statistics = await _testStatisticsDto(user.id!, testId); // Convert Test to TestDto final questionsList = []; for (final q in questions) { // Build question JSON from separate fields final questionJson = { 'questionType': q.questionType, 'id': q.id, 'word': q.word, 'answer': q.answer, }; // Parse options (JSON array of buttons / matrix cards) List buttons = []; try { buttons = json.decode(q.options) as List; } catch (_) { buttons = []; } questionJson['buttons'] = buttons; // Parse uiData (image, text, audio, template, matrixSize, ...) Map uiData = {}; try { uiData = json.decode(q.uiData) as Map; } catch (_) { uiData = {}; } var mutated = false; if (uiData['image'] != null) { final raw = uiData['image']?.toString(); final normalized = await _normalizeImageValueForDb(raw, packId: packId); if ((raw ?? '').trim() != (normalized ?? '').trim()) { mutated = true; } if (normalized == null) { uiData.remove('image'); } else { // Keep image as objectId, add imageUrl as presigned URL uiData['image'] = normalized; final imageUrl = await _imageValueToApiUrl( normalized, packId: packId, ); if (imageUrl != null) { uiData['imageUrl'] = imageUrl; } } } else { // If image was removed, also remove imageUrl uiData.remove('imageUrl'); } // Normalize button images too (so response is always URLs, never base64). // Keep image as objectId, add imageUrl as presigned URL final normalizedButtons = []; for (final b in buttons) { if (b is Map) { final buttonMap = Map.from( b.map((k, v) => MapEntry(k.toString(), v)), ); if (buttonMap['image'] != null) { final raw = buttonMap['image']?.toString(); final normalized = await _normalizeImageValueForDb(raw, packId: packId); if ((raw ?? '').trim() != (normalized ?? '').trim()) { mutated = true; } if (normalized == null) { buttonMap.remove('image'); buttonMap.remove('imageUrl'); } else { // Keep image as objectId, add imageUrl as presigned URL buttonMap['image'] = normalized; final imageUrl = await _imageValueToApiUrl( normalized, packId: packId, ); if (imageUrl != null) { buttonMap['imageUrl'] = imageUrl; } } } normalizedButtons.add(buttonMap); } else { normalizedButtons.add(b); } } questionJson['buttons'] = normalizedButtons; if (mutated) { await _db.testDao.updateTestQuestion( q.copyWith( options: jsonEncode(normalizedButtons), uiData: jsonEncode(uiData), updatedAt: PgDateTime(DateTime.now()), ), ); } // Convert audio to presigned URL if it's a UUID (objectId in MinIO) // Note: image and imageUrl were already set above during normalization final uiDataForResponse = Map.from(uiData); if (uiDataForResponse['audio'] != null) { final audioUrl = await _audioValueToApiUrl( uiDataForResponse['audio']?.toString(), ); if (audioUrl != null) { uiDataForResponse['audio'] = audioUrl; } else { // If conversion failed (e.g., base64 or invalid format), remove audio uiDataForResponse.remove('audio'); } } // Buttons already have image (objectId) and imageUrl (presigned URL) // from normalization above, no need to convert again questionJson['buttons'] = normalizedButtons; questionJson.addAll(uiDataForResponse); // Matrix question: allow storing only config (matrixSize) and generate // actual matrix cards from pack pool on-the-fly if buttons are missing. if (q.questionType == TestQuestionType.matrix.name) { final matrixSizeRaw = questionJson['matrixSize']; final matrixSize = switch (matrixSizeRaw) { int v => v, num v => v.toInt(), String v => int.tryParse(v) ?? 3, _ => 3, }; questionJson['matrixSize'] = matrixSize; final currentButtons = (questionJson['buttons'] as List?) ?? []; if (currentButtons.isEmpty && packId != null) { final pool = await _db.packDao.getPackCards(packId); final maxSize = sqrt(pool.length).floor().clamp(1, 4); final resolvedSize = matrixSize.clamp(1, maxSize); final total = resolvedSize * resolvedSize; final rng = Random( (testId.hashCode ^ q.id.hashCode ^ resolvedSize) & 0x7fffffff, ); final shuffled = pool.toList()..shuffle(rng); final selected = shuffled.take(total).toList(); final generatedButtons = selected .map( (c) => { 'id': c.id, // store image as objectId (MinIO) or filename 'image': c.image, 'original': c.original, 'translation': c.translation, }, ) .toList(); questionJson['buttons'] = generatedButtons; if (selected.isNotEmpty) { final target = selected[rng.nextInt(selected.length)]; questionJson['word'] = target.original; questionJson['answer'] = target.id; } questionJson['matrixSize'] = resolvedSize; } else if ((questionJson['answer'] as String?)?.isEmpty != false) { // If matrix cards exist but answer is missing, derive initial target // from the first card. final first = currentButtons.firstOrNull; if (first is Map) { final id = first['id']?.toString(); final original = first['original']?.toString(); if (id != null && id.isNotEmpty) { questionJson['answer'] = id; } if (original != null && original.isNotEmpty) { questionJson['word'] = original; } } } } // Convert button images to URLs (works for both TestButtonDto and matrix cards) // Add imageUrl while keeping image (objectId) for admin // All images now use the same cardImagesBucket final updatedButtons = await Future.wait( (questionJson['buttons'] as List? ?? []).map((button) async { if (button is Map && button['image'] != null) { final buttonMap = Map.from(button); final imageValue = buttonMap['image']?.toString(); // Convert image to presigned URL final imageUrl = await _imageValueToApiUrl( imageValue, packId: packId, ); if (imageUrl != null) { buttonMap['imageUrl'] = imageUrl; } return buttonMap; } return button; }), ); questionJson['buttons'] = updatedButtons; questionsList.add(AbstractTestQuestion.fromJson(questionJson)); } final normalizedCover = await _normalizeImageValueForDb(test.cover, packId: packId); final coverUrl = await _imageValueToApiUrl( normalizedCover, packId: packId, ); return TestDto( id: testId.toString(), name: test.name, color: test.color, cover: normalizedCover, // Object ID (for admin) coverUrl: coverUrl, // Presigned URL (for display) version: test.version ?? '1.0', time: test.time, timeSubtitle: test.timeSubtitle, questions: questionsList, statistics: statistics, ); } Future> availableTests(UserModel userModel) async { final tests = await _db.testDao.getAllTests(); final testDtos = []; for (final test in tests) { final questions = await _db.testDao.getTestQuestions(test.id); final statistics = await _testStatisticsDto(userModel.id!, test.id); final questionsList = questions.map((q) { // Build question JSON from separate fields final questionJson = { 'questionType': q.questionType, 'id': q.id, 'word': q.word, }; // Parse options (JSON array of buttons) try { final options = json.decode(q.options) as List; questionJson['buttons'] = options; } catch (e) { questionJson['buttons'] = []; } // Add answer questionJson['answer'] = q.answer; // Parse uiData (image, text, audio, template) try { final uiData = json.decode(q.uiData) as Map; questionJson.addAll(uiData); } catch (e) { // If uiData is empty or invalid, ignore } return AbstractTestQuestion.fromJson(questionJson); }).toList(); testDtos.add(TestDto( id: test.id.toString(), name: test.name, color: test.color, cover: test.cover, version: test.version ?? '1.0', time: test.time, timeSubtitle: test.timeSubtitle, questions: questionsList, statistics: statistics, )); } return testDtos; } Future> fetchPackTests( UserModel user, CardPackModel model, ) async { final packId = model.id; if (packId == null) return []; final tests = await _db.testDao.getTestsByPackId(packId); final testDtos = []; for (final test in tests) { final questions = await _db.testDao.getTestQuestions(test.id); final statistics = await _testStatisticsDto(user.id!, test.id); final questionsList = await Future.wait( questions.map((q) async { // Build question JSON from separate fields final questionJson = { 'questionType': q.questionType, 'id': q.id, 'word': q.word, }; // Parse options (JSON array of buttons) List buttons = []; try { buttons = json.decode(q.options) as List; } catch (e) { buttons = []; } // Convert button images to URLs (works for both TestButtonDto and matrix cards) // Add imageUrl while keeping image (objectId) for admin final updatedButtons = await Future.wait( buttons.map((button) async { if (button is Map && button['image'] != null) { final buttonMap = Map.from(button); final imageValue = buttonMap['image']?.toString(); // Convert image to presigned URL final imageUrl = await _imageValueToApiUrl( imageValue, packId: packId, ); if (imageUrl != null) { buttonMap['imageUrl'] = imageUrl; } return buttonMap; } return button; }), ); questionJson['buttons'] = updatedButtons; // Add answer questionJson['answer'] = q.answer; // Parse uiData (image, text, audio, template) try { final uiData = json.decode(q.uiData) as Map; // Convert question image to URL if (uiData['image'] != null) { final imageValue = uiData['image']?.toString(); final imageUrl = await _imageValueToApiUrl( imageValue, packId: packId, ); if (imageUrl != null) { uiData['imageUrl'] = imageUrl; } } questionJson.addAll(uiData); } catch (e) { // If uiData is empty or invalid, ignore } return AbstractTestQuestion.fromJson(questionJson); }), ); final normalizedCover = await _normalizeImageValueForDb(test.cover, packId: packId); final coverUrl = await _imageValueToApiUrl( normalizedCover, packId: packId, ); testDtos.add(TestDto( id: test.id.toString(), name: test.name, color: test.color, cover: normalizedCover, // Object ID (for admin) coverUrl: coverUrl, // Presigned URL (for display) version: test.version ?? '1.0', time: test.time, timeSubtitle: test.timeSubtitle, questions: questionsList, statistics: statistics, )); } return testDtos; } Future refreshCustomCreationTestsData() async { // Load custom creation test data from database // For now, keep it simple } Future updateGeneratedTests(CardPackModel model) async { final packId = model.id; if (packId == null) return; // Get pack data from database final pack = await _db.packDao.getPackById(packId); if (pack == null) return; // Get all cards for the pack final cards = await _db.packDao.getPackCards(packId); if (cards.isEmpty) return; // Delete old generated tests for this pack final existingTests = await _db.testDao.getTestsByPackId(packId); final oldGeneratedTests = existingTests .where((test) => test.version == 'generated') .toList(); for (final oldTest in oldGeneratedTests) { await _db.testDao.softDeleteTest(oldTest.id); } // Create creation test data final testDataItems = cards.map((card) { return TestDataItem( id: card.id.toString(), original: card.original, translation: card.translation, image: card.image, audio: card.original, // Use original as audio ); }).toList(); final creationTestData = CreationTestData( items: testDataItems, title: pack.title, color: pack.color, packId: packId.toString(), ); // Generate test using PackTestGenerator final generator = PackTestGenerator(creationTestData); final testDto = await generator.generate( name: '${pack.title} Test', ratios: { TestQuestionType.simple: 0.6, TestQuestionType.input_buttons: 0.2, TestQuestionType.matrix: 0.2, }, multiply: 1.0, ); // Save test to database and link it to the pack in one transaction. await addTest( testDto, packId: packId, ); } Future addTest( TestDto testDto, { String? packId, }) async { String? createdTestId; await _db.transaction(() async { // Create test final testCompanion = TestsCompanion.insert( name: testDto.name, color: drift.Value(testDto.color), cover: drift.Value(testDto.cover), version: drift.Value(testDto.version), time: drift.Value(testDto.time), timeSubtitle: drift.Value(testDto.timeSubtitle), ); createdTestId = await _db.testDao.createTest(testCompanion); final testId = createdTestId!; if (packId != null) { await _db.testDao.linkTestToPack(testId, packId); } // Create questions int orderIndex = 0; for (final question in testDto.questions) { final questionJson = question.toJson(); // Extract key fields final word = questionJson['word'] as String? ?? ''; final answer = questionJson['answer'] as String? ?? ''; final buttons = questionJson['buttons'] as List? ?? []; // UI data (image, text, audio, template) final uiData = {}; if (questionJson['image'] != null) uiData['image'] = questionJson['image']; if (questionJson['text'] != null) uiData['text'] = questionJson['text']; if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio']; if (questionJson['template'] != null) uiData['template'] = questionJson['template']; if (questionJson['matrixSize'] != null) uiData['matrixSize'] = questionJson['matrixSize']; final questionCompanion = TestQuestionsCompanion.insert( testId: testId, orderIndex: drift.Value(orderIndex++), questionType: question.questionType.name, word: word, answer: answer, options: drift.Value(json.encode(buttons)), uiData: drift.Value(json.encode(uiData)), ); await _db.testDao.createTestQuestion(questionCompanion); } }); return createdTestId!; } }