import 'dart:async'; import 'dart:convert'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../local/local_storage_service.dart'; import '../nostr/nostr_service.dart'; import '../nostr/models/nostr_event.dart'; import '../nostr/models/nostr_keypair.dart'; import '../../core/logger.dart'; import '../../core/service_locator.dart'; import 'models/recipe_model.dart'; /// Service for managing recipes with local storage and Nostr sync. class RecipeService { /// Local storage service. final LocalStorageService _localStorage; /// Nostr service (optional). final NostrService? _nostrService; /// Nostr keypair for signing events (optional). NostrKeyPair? _nostrKeyPair; /// Database instance (null until initialized). Database? _db; /// Current database path (for debugging and verification). String? _currentDbPath; /// Optional database path for testing (null uses default). final String? _testDbPath; /// Creates a [RecipeService] instance. RecipeService({ required LocalStorageService localStorage, NostrService? nostrService, NostrKeyPair? nostrKeyPair, String? testDbPath, }) : _localStorage = localStorage, _nostrService = nostrService, _nostrKeyPair = nostrKeyPair, _testDbPath = testDbPath; /// Sets the Nostr keypair for signing events. void setNostrKeyPair(NostrKeyPair keypair) { _nostrKeyPair = keypair; } /// Initializes the recipes table in the database. /// /// Must be called before using any other methods. Future initialize({String? sessionDbPath}) async { await _localStorage.initialize(); // Get database path final dbPath = sessionDbPath ?? _testDbPath ?? await _getDatabasePath(); // Close existing database if switching sessions if (_db != null) { await _db!.close(); _db = null; } // Open database _db = await openDatabase( dbPath, version: 1, onCreate: _onCreate, ); // Store the current database path _currentDbPath = dbPath; Logger.info('RecipeService initialized with database: $dbPath'); // Log the database path for debugging Logger.debug('Current RecipeService database path: $dbPath'); } /// Reinitializes the service with a new database path (for session switching). /// /// [newDbPath] - New database path to use. /// /// Throws [Exception] if reinitialization fails. Future reinitializeForSession({ required String newDbPath, }) async { await initialize(sessionDbPath: newDbPath); } /// Creates the recipes table if it doesn't exist. Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE IF NOT EXISTS recipes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, tags TEXT NOT NULL, rating INTEGER NOT NULL DEFAULT 0, is_favourite INTEGER NOT NULL DEFAULT 0, image_urls TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, nostr_event_id TEXT ) '''); // Create index for faster queries await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_created_at ON recipes(created_at DESC) '''); await db.execute(''' CREATE INDEX IF NOT EXISTS idx_recipes_is_deleted ON recipes(is_deleted) '''); } /// Gets the path to the database file. /// /// Note: This is the default path. For user-specific paths, use initialize(sessionDbPath: ...) /// or reinitializeForSession(newDbPath: ...). Future _getDatabasePath() async { final documentsDirectory = await getApplicationDocumentsDirectory(); return path.join(documentsDirectory.path, 'recipes.db'); } /// Ensures the database is initialized and open. void _ensureInitialized() { if (_db == null) { throw Exception('RecipeService not initialized. Call initialize() first.'); } // Check if database is still open (sqflite doesn't have isOpen, but we can catch the error) // If database was closed, we need to reinitialize // Note: We can't easily check if database is open without trying to use it // The error will be caught by the calling method } /// Ensures the database is initialized, and reinitializes if needed. /// This is called before operations that might fail if the database was closed. Future _ensureInitializedOrReinitialize() async { if (_db == null) { // Try to reinitialize with the current user's database path final sessionService = ServiceLocator.instance.sessionService; if (sessionService != null && sessionService.currentUser != null) { final user = sessionService.currentUser!; try { // Get the user-specific database path final appDir = await getApplicationDocumentsDirectory(); final recipesDbPath = path.join(appDir.path, 'users', user.id, 'recipes.db'); await reinitializeForSession(newDbPath: recipesDbPath); Logger.info('RecipeService reinitialized automatically'); } catch (e) { Logger.warning('Failed to auto-reinitialize RecipeService: $e'); throw Exception('RecipeService not initialized. Call initialize() first.'); } } else { throw Exception('RecipeService not initialized. Call initialize() first.'); } } } /// Creates a new recipe. /// /// [recipe] - The recipe to create. /// /// Returns the created recipe with updated timestamps. /// /// Throws [Exception] if creation fails. Future createRecipe(RecipeModel recipe) async { // Ensure database is initialized, or reinitialize if needed await _ensureInitializedOrReinitialize(); try { final now = DateTime.now().millisecondsSinceEpoch; final recipeWithTimestamps = recipe.copyWith( createdAt: now, updatedAt: now, ); await _db!.insert( 'recipes', recipeWithTimestamps.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.info('Recipe created: ${recipe.id}'); Logger.debug('Recipe stored in database at: $_currentDbPath'); // Publish to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { Logger.info('Publishing recipe ${recipe.id} to Nostr...'); await _publishRecipeToNostr(recipeWithTimestamps); Logger.info('Recipe ${recipe.id} published to Nostr successfully'); } catch (e) { Logger.warning('Failed to publish recipe to Nostr: $e'); // Don't fail the creation if Nostr publish fails } } else { if (_nostrService == null) { Logger.warning('NostrService not available, skipping recipe publish'); } if (_nostrKeyPair == null) { Logger.warning('Nostr keypair not set, skipping recipe publish. Call setNostrKeyPair() first.'); } } return recipeWithTimestamps; } catch (e) { throw Exception('Failed to create recipe: $e'); } } /// Updates an existing recipe. /// /// [recipe] - The recipe with updated data. /// /// Returns the updated recipe. /// /// Throws [Exception] if update fails or recipe doesn't exist. Future updateRecipe(RecipeModel recipe) async { _ensureInitialized(); try { final updatedRecipe = recipe.copyWith( updatedAt: DateTime.now().millisecondsSinceEpoch, ); final updated = await _db!.update( 'recipes', updatedRecipe.toMap(), where: 'id = ?', whereArgs: [recipe.id], ); if (updated == 0) { throw Exception('Recipe with id ${recipe.id} not found'); } Logger.info('Recipe updated: ${recipe.id}'); // Publish to Nostr if available if (_nostrService != null && _nostrKeyPair != null) { try { await _publishRecipeToNostr(updatedRecipe); } catch (e) { Logger.warning('Failed to publish recipe update to Nostr: $e'); // Don't fail the update if Nostr publish fails } } return updatedRecipe; } catch (e) { throw Exception('Failed to update recipe: $e'); } } /// Deletes a recipe. /// /// [recipeId] - The ID of the recipe to delete. /// /// Throws [Exception] if deletion fails. Future deleteRecipe(String recipeId) async { await _ensureInitializedOrReinitialize(); try { // Get the recipe BEFORE marking it as deleted (to get nostrEventId) final recipe = await getRecipe(recipeId, includeDeleted: false); final nostrEventId = recipe?.nostrEventId; // Mark as deleted instead of actually deleting final updated = await _db!.update( 'recipes', { 'is_deleted': 1, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [recipeId], ); if (updated == 0) { throw Exception('Recipe with id $recipeId not found'); } Logger.info('Recipe deleted: $recipeId'); // Publish deletion event to Nostr if available if (_nostrService != null && _nostrKeyPair != null && nostrEventId != null) { try { await _publishRecipeDeletionToNostr(recipeId, nostrEventId); } catch (e) { Logger.warning('Failed to publish recipe deletion to Nostr: $e'); // Don't fail the deletion if Nostr publish fails } } else if (nostrEventId == null) { Logger.warning('Recipe $recipeId has no Nostr event ID, skipping deletion event'); } } catch (e) { throw Exception('Failed to delete recipe: $e'); } } /// Gets a recipe by ID. /// /// [recipeId] - The ID of the recipe. /// [includeDeleted] - Whether to include deleted recipes (default: false). /// /// Returns the recipe if found, null otherwise. /// /// Throws [Exception] if retrieval fails. Future getRecipe(String recipeId, {bool includeDeleted = false}) async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'recipes', where: includeDeleted ? 'id = ?' : 'id = ? AND is_deleted = 0', whereArgs: [recipeId], limit: 1, ); if (maps.isEmpty) { return null; } return RecipeModel.fromMap(maps.first); } catch (e) { throw Exception('Failed to get recipe: $e'); } } /// Gets all recipes. /// /// [includeDeleted] - Whether to include deleted recipes (default: false). /// /// Returns a list of all recipes, ordered by creation date (newest first). /// /// Throws [Exception] if retrieval fails. Future> getAllRecipes({bool includeDeleted = false}) async { try { // Try to ensure database is initialized, or reinitialize if needed await _ensureInitializedOrReinitialize(); final List> maps = await _db!.query( 'recipes', where: includeDeleted ? null : 'is_deleted = 0', orderBy: 'created_at DESC', ); Logger.debug('getAllRecipes: Querying database at: $_currentDbPath'); Logger.debug('getAllRecipes: Found ${maps.length} recipe(s) in database'); final recipeList = maps.map((map) => RecipeModel.fromMap(map)).toList(); Logger.debug('getAllRecipes: Converted to ${recipeList.length} RecipeModel(s)'); if (recipeList.isNotEmpty) { Logger.debug('getAllRecipes: First recipe: ${recipeList.first.title} (id: ${recipeList.first.id})'); } return recipeList; } catch (e) { // If error is about database being closed, try to reinitialize once final errorStr = e.toString().toLowerCase(); if (errorStr.contains('database_closed')) { try { Logger.warning('Database was closed, attempting to reinitialize...'); // Clear the closed database reference _db = null; // Reinitialize with current user's database path await _ensureInitializedOrReinitialize(); // Retry the query final List> maps = await _db!.query( 'recipes', where: includeDeleted ? null : 'is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (retryError) { throw Exception('Failed to get all recipes: $retryError'); } } throw Exception('Failed to get all recipes: $e'); } } /// Gets recipes by tag. /// /// [tag] - The tag to filter by. /// /// Returns a list of recipes with the specified tag. /// /// Throws [Exception] if retrieval fails. Future> getRecipesByTag(String tag) async { try { final allRecipes = await getAllRecipes(); return allRecipes.where((recipe) => recipe.tags.contains(tag)).toList(); } catch (e) { throw Exception('Failed to get recipes by tag: $e'); } } /// Gets favourite recipes. /// /// Returns a list of recipes marked as favourite. /// /// Throws [Exception] if retrieval fails. Future> getFavouriteRecipes() async { await _ensureInitializedOrReinitialize(); try { final List> maps = await _db!.query( 'recipes', where: 'is_favourite = 1 AND is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (e) { // If error is about database being closed, try to reinitialize once final errorStr = e.toString().toLowerCase(); if (errorStr.contains('database_closed')) { try { Logger.warning('Database was closed, attempting to reinitialize...'); _db = null; await _ensureInitializedOrReinitialize(); final List> maps = await _db!.query( 'recipes', where: 'is_favourite = 1 AND is_deleted = 0', orderBy: 'created_at DESC', ); return maps.map((map) => RecipeModel.fromMap(map)).toList(); } catch (retryError) { throw Exception('Failed to get favourite recipes: $retryError'); } } throw Exception('Failed to get favourite recipes: $e'); } } /// Clears all recipes from the database. /// /// Used on logout to ensure user data isolation. /// /// Throws [Exception] if clearing fails. Future clearAllRecipes() async { _ensureInitialized(); try { await _db!.delete('recipes'); Logger.info('All recipes cleared from database'); } catch (e) { throw Exception('Failed to clear recipes: $e'); } } /// Closes the database connection. /// /// Should be called when switching users or cleaning up. Future close() async { if (_db != null) { await _db!.close(); _db = null; _currentDbPath = null; Logger.info('RecipeService database closed'); } } /// Fetches recipes from Nostr for a given public key. /// /// Queries kind 30000 events (NIP-33 parameterized replaceable events) /// from enabled relays and stores them locally. /// /// [publicKey] - The public key (hex format) to fetch recipes for. /// [timeout] - Timeout for the request (default: 30 seconds). /// /// Returns the number of recipes fetched and stored. /// /// Throws [Exception] if fetch fails. Future fetchRecipesFromNostr( String publicKey, { Duration timeout = const Duration(seconds: 30), }) async { if (_nostrService == null) { throw Exception('Nostr service not available'); } // Ensure database is initialized before fetching await _ensureInitializedOrReinitialize(); try { Logger.info('Fetching recipes from Nostr for public key: ${publicKey.substring(0, 16)}...'); // Get all enabled relays final enabledRelays = _nostrService!.getRelays() .where((relay) => relay.isEnabled) .toList(); if (enabledRelays.isEmpty) { Logger.warning('No enabled relays available for fetching recipes'); return 0; } // Try to fetch from connected relays first final recipes = []; final Set seenRecipeIds = {}; // Track by recipe ID to handle duplicates for (final relay in enabledRelays) { if (!relay.isConnected) { try { await _nostrService!.connectRelay(relay.url).timeout( const Duration(seconds: 5), onTimeout: () { throw Exception('Connection timeout'); }, ); } catch (e) { Logger.warning('Failed to connect to relay ${relay.url}: $e'); continue; } } try { final relayRecipes = await _fetchRecipesFromRelay( publicKey, relay.url, timeout, ); // Add recipes, avoiding duplicates (by recipe ID from 'd' tag) for (final recipe in relayRecipes) { if (!seenRecipeIds.contains(recipe.id)) { recipes.add(recipe); seenRecipeIds.add(recipe.id); } } } catch (e) { Logger.warning('Failed to fetch recipes from relay ${relay.url}: $e'); // Continue to next relay } } // Store fetched recipes in database int storedCount = 0; for (final recipe in recipes) { try { // Check if recipe already exists (by ID) final existing = await getRecipe(recipe.id); if (existing == null) { // Insert new recipe await _db!.insert( 'recipes', recipe.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); Logger.debug('Stored recipe ${recipe.id} (${recipe.title}) in database'); storedCount++; } else { // Update existing recipe (might have newer data from Nostr) await updateRecipe(recipe); storedCount++; } } catch (e) { Logger.warning('Failed to store recipe ${recipe.id}: $e'); // Continue with other recipes } } Logger.info('Fetched and stored $storedCount recipe(s) from Nostr'); return storedCount; } catch (e) { Logger.error('Failed to fetch recipes from Nostr', e); throw Exception('Failed to fetch recipes from Nostr: $e'); } } /// Fetches recipes from a specific relay. /// /// Uses NostrService's internal message stream to query events. Future> _fetchRecipesFromRelay( String publicKey, String relayUrl, Duration timeout, ) async { // We need to use NostrService's queryEvents method or access the stream // For now, we'll use a similar approach to fetchProfile // This requires accessing the message stream, which we'll do via a helper return await _queryRecipeEventsFromRelay(publicKey, relayUrl, timeout); } /// Queries recipe events from a relay using NostrService's queryEvents method. Future> _queryRecipeEventsFromRelay( String publicKey, String relayUrl, Duration timeout, ) async { try { // Use NostrService's queryEvents method to get kind 30000 events final events = await _nostrService!.queryEvents( publicKey, relayUrl, [30000], timeout: timeout, ); final Map recipeMap = {}; // Track by 'd' tag value for (final event in events) { try { // Extract recipe ID from 'd' tag String? recipeId; for (final tag in event.tags) { if (tag.isNotEmpty && tag[0] == 'd') { recipeId = tag.length > 1 ? tag[1] : null; break; } } if (recipeId == null) { Logger.warning('Recipe event missing "d" tag: ${event.id}'); continue; } // Parse content as JSON final contentMap = jsonDecode(event.content) as Map; // Extract additional metadata from tags final imageUrls = []; final tags = []; int rating = 0; bool isFavourite = false; for (final tag in event.tags) { if (tag.isEmpty) continue; switch (tag[0]) { case 'image': if (tag.length > 1) imageUrls.add(tag[1]); break; case 't': if (tag.length > 1) tags.add(tag[1]); break; case 'rating': if (tag.length > 1) { rating = int.tryParse(tag[1]) ?? 0; } break; case 'favourite': if (tag.length > 1) { isFavourite = tag[1].toLowerCase() == 'true'; } break; } } // Create RecipeModel from event final recipe = RecipeModel( id: recipeId, title: contentMap['title'] as String? ?? 'Untitled Recipe', description: contentMap['description'] as String?, tags: tags.isNotEmpty ? tags : (contentMap['tags'] as List?)?.map((e) => e.toString()).toList() ?? [], rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0), isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false), imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), nostrEventId: event.id, ); // Use replaceable event logic: keep the latest version final existing = recipeMap[recipeId]; if (existing == null || recipe.updatedAt > existing.updatedAt) { recipeMap[recipeId] = recipe; } } catch (e) { Logger.warning('Failed to parse recipe from event ${event.id}: $e'); } } return recipeMap.values.toList(); } catch (e) { Logger.error('Failed to query recipes from relay $relayUrl', e); rethrow; } } /// Publishes a recipe to Nostr as a kind 30000 event (NIP-33 parameterized replaceable event). Future _publishRecipeToNostr(RecipeModel recipe) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Create tags final tags = >[ ['d', recipe.id], // Replaceable event identifier ]; // Add image tags for (final imageUrl in recipe.imageUrls) { tags.add(['image', imageUrl]); } // Add tag tags for (final tag in recipe.tags) { tags.add(['t', tag]); } // Add rating tag tags.add(['rating', recipe.rating.toString()]); // Add favourite tag tags.add(['favourite', recipe.isFavourite.toString()]); // Create event content as JSON final content = recipe.toJson(); // Create and sign the event // Using kind 30000 (NIP-33 parameterized replaceable event) final event = NostrEvent.create( content: jsonEncode(content), kind: 30000, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published recipe ${recipe.id} to $successCount/${results.length} relays'); // Update recipe with Nostr event ID final updatedRecipe = recipe.copyWith(nostrEventId: event.id); await _db!.update( 'recipes', updatedRecipe.toMap(), where: 'id = ?', whereArgs: [recipe.id], ); } catch (e) { Logger.error('Failed to publish recipe to Nostr', e); rethrow; } } /// Publishes a recipe deletion to Nostr as a kind 5 event. /// /// [recipeId] - The ID of the recipe being deleted. /// [nostrEventId] - The Nostr event ID of the original recipe event. Future _publishRecipeDeletionToNostr(String recipeId, String nostrEventId) async { if (_nostrService == null || _nostrKeyPair == null) { return; } try { // Create a kind 5 deletion event // Tags should reference the event IDs to delete final tags = >[ ['e', nostrEventId], // Reference the original event ]; // Create event content (can be empty for deletions) final event = NostrEvent.create( content: '', kind: 5, privateKey: _nostrKeyPair!.privateKey, tags: tags, ); // Publish to all enabled relays final results = await _nostrService!.publishEventToAllRelays(event); // Log results final successCount = results.values.where((success) => success).length; Logger.info('Published recipe deletion for $recipeId to $successCount/${results.length} relays'); } catch (e) { Logger.error('Failed to publish recipe deletion to Nostr', e); rethrow; } } }