import 'dart:io'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../../core/logger.dart'; import '../../core/exceptions/session_exception.dart'; import '../../core/service_locator.dart'; import '../local/local_storage_service.dart'; import '../local/models/item.dart'; import '../sync/sync_engine.dart'; import '../firebase/firebase_service.dart'; import '../nostr/nostr_service.dart'; import '../nostr/models/nostr_keypair.dart'; import '../nostr/models/nostr_profile.dart'; import '../blossom/blossom_service.dart'; import 'models/user.dart'; /// Service for managing user sessions, login, logout, and session isolation. /// /// This service provides: /// - User login and logout /// - Session switching between multiple users /// - Data isolation per user session /// - Cache clearing on logout /// - Integration with local storage and sync engine /// /// The service is modular and UI-independent, designed for offline-first behavior. class SessionService { /// Current active user session (null if no user is logged in). User? _currentUser; /// Local storage service for data persistence. final LocalStorageService _localStorage; /// Sync engine for coordinating sync operations (optional). final SyncEngine? _syncEngine; /// Firebase service for optional cloud sync (optional). final FirebaseService? _firebaseService; /// Nostr service for Nostr authentication (optional). final NostrService? _nostrService; /// Map of user IDs to their session storage paths. final Map _userDbPaths = {}; /// Map of user IDs to their cache directories. final Map _userCacheDirs = {}; /// Optional test database path for testing. final String? _testDbPath; /// Optional test cache directory for testing. final Directory? _testCacheDir; /// Creates a [SessionService] instance. /// /// [localStorage] - Local storage service for data persistence. /// [syncEngine] - Optional sync engine for coordinating sync operations. /// [firebaseService] - Optional Firebase service for cloud sync. /// [nostrService] - Optional Nostr service for Nostr authentication. /// [testDbPath] - Optional database path for testing. /// [testCacheDir] - Optional cache directory for testing. SessionService({ required LocalStorageService localStorage, SyncEngine? syncEngine, FirebaseService? firebaseService, NostrService? nostrService, String? testDbPath, Directory? testCacheDir, }) : _localStorage = localStorage, _syncEngine = syncEngine, _firebaseService = firebaseService, _nostrService = nostrService, _testDbPath = testDbPath, _testCacheDir = testCacheDir; /// Gets the current active user session. /// /// Returns the current [User] if logged in, null otherwise. User? get currentUser => _currentUser; /// Checks if a user is currently logged in. bool get isLoggedIn => _currentUser != null; /// Logs in a user and creates an isolated session. /// /// [id] - Unique identifier for the user. /// [username] - Display name or username. /// [token] - Optional authentication token. /// /// Returns the logged-in [User]. /// /// Throws [SessionException] if login fails or if user is already logged in. Future login({ required String id, required String username, String? token, }) async { if (_currentUser != null) { throw SessionException('User already logged in. Logout first.'); } try { final user = User( id: id, username: username, token: token, ); // Create user-specific storage paths await _setupUserStorage(user); // Sync with Firebase if enabled if (_firebaseService != null && _firebaseService!.isEnabled) { try { await _firebaseService!.syncItemsFromFirestore(user.id); } catch (e) { // Log error but don't fail login - offline-first behavior Logger.warning('Failed to sync from Firebase on login: $e'); } } // Set as current user _currentUser = user; return user; } catch (e) { throw SessionException('Failed to login: $e'); } } /// Logs in a user using Nostr key (nsec or npub). /// /// [nsecOrNpub] - Nostr key in nsec (private) or npub (public) format. /// /// Returns the logged-in [User] with fetched profile data. /// /// Throws [SessionException] if login fails or if user is already logged in. Future loginWithNostr(String nsecOrNpub) async { if (_currentUser != null) { throw SessionException('User already logged in. Logout first.'); } if (_nostrService == null) { throw SessionException('Nostr service not available'); } try { // Parse the key NostrKeyPair keyPair; String? storedPrivateKey; if (nsecOrNpub.startsWith('nsec')) { keyPair = NostrKeyPair.fromNsec(nsecOrNpub); // Store the nsec for event publishing storedPrivateKey = nsecOrNpub; } else if (nsecOrNpub.startsWith('npub')) { keyPair = NostrKeyPair.fromNpub(nsecOrNpub); // No private key available when using npub storedPrivateKey = null; } else { throw SessionException('Invalid Nostr key format. Expected nsec or npub.'); } // Fetch profile from relays NostrProfile? profile; try { profile = await _nostrService!.fetchProfile(keyPair.publicKey); } catch (e) { Logger.warning('Failed to fetch Nostr profile: $e'); // Continue without profile - offline-first behavior } // Create user with Nostr profile and private key (if available) final user = User( id: keyPair.publicKey, username: profile?.displayName ?? keyPair.publicKey.substring(0, 16), nostrProfile: profile, nostrPrivateKey: storedPrivateKey, ); // Create user-specific storage paths await _setupUserStorage(user); // Set Nostr keypair on RecipeService for signing recipe events final recipeService = ServiceLocator.instance.recipeService; if (recipeService != null && storedPrivateKey != null) { try { recipeService.setNostrKeyPair(keyPair); Logger.info('Nostr keypair set on RecipeService for recipe publishing'); } catch (e) { Logger.warning('Failed to set Nostr keypair on RecipeService: $e'); } } // Set Nostr keypair on BlossomService if Blossom is the selected media service final mediaService = ServiceLocator.instance.mediaService; if (mediaService != null && storedPrivateKey != null) { try { // Check if it's a BlossomService by checking if it has setNostrKeyPair method // Use a more reliable check by trying to call the method if (mediaService is BlossomService) { mediaService.setNostrKeyPair(keyPair); Logger.info('Nostr keypair set on BlossomService for image uploads'); } else { // Fallback: try dynamic call if type check doesn't work try { (mediaService as dynamic).setNostrKeyPair(keyPair); Logger.info('Nostr keypair set on media service (dynamic) for image uploads'); } catch (_) { Logger.debug('Media service does not support Nostr keypair: ${mediaService.runtimeType}'); } } } catch (e) { Logger.warning('Failed to set Nostr keypair on BlossomService: $e'); } } // Sync with Firebase if enabled if (_firebaseService != null && _firebaseService!.isEnabled) { try { await _firebaseService!.syncItemsFromFirestore(user.id); } catch (e) { // Log error but don't fail login - offline-first behavior Logger.warning('Failed to sync from Firebase on login: $e'); } } // Set as current user _currentUser = user; // Load preferred relays from NIP-05 if available await _loadPreferredRelaysIfAvailable(); // Wait a bit for relay connections to establish before fetching recipes // This ensures that if NIP-05 relays were automatically replaced and enabled, // they have time to connect before we try to fetch recipes await Future.delayed(const Duration(milliseconds: 500)); // Fetch recipes from Nostr for this user await _fetchRecipesForUser(user); return user; } catch (e) { throw SessionException('Failed to login with Nostr: $e'); } } /// Updates the current user's profile. /// /// [updatedUser] - The user with updated profile information. /// /// Throws [SessionException] if update fails or if no user is logged in. Future updateUserProfile(User updatedUser) async { if (_currentUser == null) { throw SessionException('No user logged in.'); } if (_currentUser!.id != updatedUser.id) { throw SessionException('Cannot update different user profile.'); } try { // Update current user _currentUser = updatedUser; // Optionally refresh profile from Nostr to ensure consistency if (updatedUser.nostrProfile != null && _nostrService != null) { try { final refreshedProfile = await _nostrService!.fetchProfile(updatedUser.id); if (refreshedProfile != null) { _currentUser = updatedUser.copyWith(nostrProfile: refreshedProfile); } } catch (e) { // Log but don't fail - offline-first behavior Logger.warning('Failed to refresh profile from Nostr: $e'); } } } catch (e) { throw SessionException('Failed to update user profile: $e'); } } /// Logs out the current user and clears session data. /// /// [clearCache] - Whether to clear cached data (default: true). /// /// Throws [SessionException] if logout fails or if no user is logged in. Future logout({bool clearCache = true}) async { if (_currentUser == null) { throw SessionException('No user logged in.'); } try { final userId = _currentUser!.id; // Sync to Firebase before logout if enabled if (_firebaseService != null && _firebaseService!.isEnabled) { try { await _firebaseService!.syncItemsToFirestore(userId); } catch (e) { // Log error but don't fail logout - offline-first behavior Logger.warning('Failed to sync to Firebase on logout: $e'); } } // Clear user-specific data if requested if (clearCache) { // Close and clear recipes first (before deleting database file) await _clearRecipesForUser(); // Then clear other user data and delete database files await _clearUserData(userId); } // Clear current user _currentUser = null; // Clean up user storage references _userDbPaths.remove(userId); _userCacheDirs.remove(userId); } catch (e) { throw SessionException('Failed to logout: $e'); } } /// Switches to a different user session. /// /// This method logs out the current user and logs in the new user. /// /// [id] - Unique identifier for the new user. /// [username] - Display name or username for the new user. /// [token] - Optional authentication token for the new user. /// [clearCache] - Whether to clear cached data for the previous user (default: true). /// /// Returns the new logged-in [User]. /// /// Throws [SessionException] if switch fails. Future switchSession({ required String id, required String username, String? token, bool clearCache = true, }) async { if (_currentUser != null) { await logout(clearCache: clearCache); } return await login( id: id, username: username, token: token, ); } /// Sets up user-specific storage paths and reinitializes local storage. /// /// [user] - The user to set up storage for. Future _setupUserStorage(User user) async { try { // Get user-specific database path // In test mode, create per-user paths under the test directory // In production, create per-user paths under app documents String dbPath; if (_testDbPath != null) { // Create user-specific path under test directory final testDir = path.dirname(_testDbPath!); dbPath = path.join(testDir, 'user_${user.id}_storage.db'); } else { final appDir = await getApplicationDocumentsDirectory(); dbPath = path.join(appDir.path, 'users', user.id, 'storage.db'); } _userDbPaths[user.id] = dbPath; // Get user-specific cache directory Directory cacheDir; if (_testCacheDir != null) { // Create user-specific cache under test directory final testCacheParent = _testCacheDir!.path; cacheDir = Directory(path.join(testCacheParent, 'user_${user.id}')); } else { final appDir = await getApplicationDocumentsDirectory(); cacheDir = Directory(path.join(appDir.path, 'users', user.id, 'cache')); } if (!await cacheDir.exists()) { await cacheDir.create(recursive: true); } _userCacheDirs[user.id] = cacheDir; // Reinitialize local storage with user-specific paths await _localStorage.reinitializeForSession( newDbPath: dbPath, newCacheDir: cacheDir, ); // Reinitialize recipe service with user-specific database path final recipeService = ServiceLocator.instance.recipeService; if (recipeService != null) { // Create user-specific recipes database path String recipesDbPath; if (_testDbPath != null) { // Create user-specific path under test directory final testDir = path.dirname(_testDbPath!); recipesDbPath = path.join(testDir, 'user_${user.id}_recipes.db'); } else { final appDir = await getApplicationDocumentsDirectory(); final userDir = Directory(path.join(appDir.path, 'users', user.id)); // Ensure user directory exists (it should from _setupUserStorage, but be safe) if (!await userDir.exists()) { await userDir.create(recursive: true); } recipesDbPath = path.join(userDir.path, 'recipes.db'); } try { await recipeService.reinitializeForSession(newDbPath: recipesDbPath); Logger.info('RecipeService reinitialized with user-specific database: $recipesDbPath'); } catch (e) { Logger.warning('Failed to reinitialize RecipeService for user: $e'); // Don't fail login if recipe service reinitialization fails } } } catch (e) { throw SessionException('Failed to setup user storage: $e'); } } /// Clears all data for a specific user. /// /// [userId] - The ID of the user whose data should be cleared. Future _clearUserData(String userId) async { try { // Clear all data from local storage if it's the current user's storage // But preserve app_settings (user-independent settings) if (_currentUser?.id == userId) { // Save app_settings before clearing Item? appSettings; try { appSettings = await _localStorage.getItem('app_settings'); } catch (e) { // If we can't read it, that's okay - it might not exist Logger.debug('Could not read app_settings before clearing: $e'); } // Clear all data await _localStorage.clearAllData(); // Restore app_settings if it existed if (appSettings != null) { try { await _localStorage.insertItem(appSettings); Logger.debug('Preserved app_settings during logout'); } catch (e) { Logger.warning('Failed to restore app_settings after logout: $e'); } } } // Delete user-specific recipes database file // Note: RecipeService database should already be closed by _clearRecipesForUser() try { final appDir = await getApplicationDocumentsDirectory(); final recipesDbPath = path.join(appDir.path, 'users', userId, 'recipes.db'); final recipesDbFile = File(recipesDbPath); if (await recipesDbFile.exists()) { // Wait a brief moment to ensure database is fully closed await Future.delayed(const Duration(milliseconds: 100)); await recipesDbFile.delete(); Logger.info('Deleted user-specific recipes database: $recipesDbPath'); } } catch (e) { Logger.warning('Failed to delete user recipes database: $e'); // Don't fail logout if database deletion fails } // Clear cache directory final cacheDir = _userCacheDirs[userId]; if (cacheDir != null && await cacheDir.exists()) { await cacheDir.delete(recursive: true); } // Clear database path reference _userDbPaths.remove(userId); _userCacheDirs.remove(userId); // Notify sync engine if available if (_syncEngine != null) { // Sync engine will handle session cleanup internally // This is a placeholder for future sync engine integration } } catch (e) { throw SessionException('Failed to clear user data: $e'); } } /// Fetches recipes from Nostr for the logged-in user. /// /// This is called automatically on login to sync recipes from relays. /// /// [user] - The user to fetch recipes for. Future _fetchRecipesForUser(User user) async { try { final recipeService = ServiceLocator.instance.recipeService; if (recipeService == null) { Logger.warning('RecipeService not available, skipping recipe fetch'); return; } // Only fetch if user has a Nostr profile or private key (indicating Nostr login) if (user.nostrProfile == null && user.nostrPrivateKey == null) { Logger.info('User does not have Nostr profile or key, skipping recipe fetch'); return; } // Get public key from user ID (which is the Nostr public key in hex format) final publicKey = user.id; try { final count = await recipeService.fetchRecipesFromNostr(publicKey); Logger.info('Fetched $count recipe(s) from Nostr for user ${user.username}'); } catch (e) { // Log error but don't fail login - offline-first behavior Logger.warning('Failed to fetch recipes from Nostr on login: $e'); } } catch (e) { Logger.warning('Error in _fetchRecipesForUser: $e'); // Don't throw - this is a background operation } } /// Clears all recipes from the database. /// /// This is called automatically on logout to ensure user data isolation. Future _clearRecipesForUser() async { try { final recipeService = ServiceLocator.instance.recipeService; if (recipeService == null) { Logger.warning('RecipeService not available, skipping recipe cleanup'); return; } // Close database connection first to ensure it's not open when we delete the file await recipeService.close(); // Note: We don't need to clear recipes if we're deleting the database file anyway // But if we want to be safe, we could clear first, but it's not necessary Logger.info('Closed RecipeService database on logout'); } catch (e) { // Log error but don't fail logout - offline-first behavior Logger.warning('Failed to close RecipeService on logout: $e'); } } /// Gets the database path for the current user. /// /// Returns the database path if user is logged in, null otherwise. String? getCurrentUserDbPath() { if (_currentUser == null) return null; return _userDbPaths[_currentUser!.id]; } /// Gets the cache directory for the current user. /// /// Returns the cache directory if user is logged in, null otherwise. Directory? getCurrentUserCacheDir() { if (_currentUser == null) return null; return _userCacheDirs[_currentUser!.id]; } /// Loads preferred relays from NIP-05 if the current user has an active nip05. /// /// This method checks if the logged-in user has a nip05 identifier and, /// if so, fetches preferred relays from the NIP-05 verification endpoint /// and adds them to the Nostr service relay list. /// /// This is called automatically after Nostr login, but can also be called /// manually to refresh preferred relays. Future loadPreferredRelaysIfAvailable() async { await _loadPreferredRelaysIfAvailable(); } /// Refreshes the current user's Nostr profile and preferred relays. /// /// Re-fetches the profile from relays and reloads preferred relays from NIP-05. /// /// Throws [SessionException] if refresh fails or if no user is logged in. Future refreshNostrProfile() async { if (_currentUser == null) { throw SessionException('No user logged in.'); } if (_nostrService == null) { throw SessionException('Nostr service not available'); } // Only refresh if user has Nostr profile (logged in with Nostr) if (_currentUser!.nostrProfile == null && _currentUser!.nostrPrivateKey == null) { throw SessionException('User is not logged in with Nostr'); } try { // Re-fetch profile from relays NostrProfile? profile; try { profile = await _nostrService!.fetchProfile(_currentUser!.id); } catch (e) { Logger.warning('Failed to refresh Nostr profile: $e'); // Continue without profile update - offline-first behavior } // Update user with refreshed profile if (profile != null) { _currentUser = _currentUser!.copyWith(nostrProfile: profile); } // Reload preferred relays from NIP-05 if available await _loadPreferredRelaysIfAvailable(); } catch (e) { if (e is SessionException) { rethrow; } throw SessionException('Failed to refresh Nostr profile: $e'); } } /// Internal method to load preferred relays from NIP-05 if available. /// /// If the "use NIP-05 relays automatically" setting is enabled, /// this will replace all existing relays with preferred relays. /// Otherwise, it will just add preferred relays to the existing list. Future _loadPreferredRelaysIfAvailable() async { if (_currentUser == null || _nostrService == null) { return; } final profile = _currentUser!.nostrProfile; if (profile == null || profile.nip05 == null || profile.nip05!.isEmpty) { return; } try { // Check if automatic NIP-05 relay replacement is enabled bool useNip05RelaysAutomatically = false; try { final settingsItem = await _localStorage.getItem('app_settings'); if (settingsItem != null && settingsItem.data.containsKey('use_nip05_relays_automatically')) { useNip05RelaysAutomatically = settingsItem.data['use_nip05_relays_automatically'] == true; } } catch (e) { // If we can't read the setting, default to false (just add relays, don't replace) Logger.warning('Failed to read NIP-05 relay setting: $e'); } final nip05 = profile.nip05!; final publicKey = _currentUser!.id; // User ID is the public key for Nostr users if (useNip05RelaysAutomatically) { // Replace all relays with preferred relays from NIP-05 final addedCount = await _nostrService!.replaceRelaysWithPreferredFromNip05( nip05, publicKey, ); if (addedCount > 0) { Logger.info('Automatically replaced relays with $addedCount preferred relay(s) from NIP-05: $nip05'); } } else { // Just add preferred relays to existing list final addedCount = await _nostrService!.loadPreferredRelaysFromNip05( nip05, publicKey, ); if (addedCount > 0) { Logger.info('Loaded $addedCount preferred relay(s) from NIP-05: $nip05'); } } } catch (e) { // Log error but don't fail - offline-first behavior Logger.warning('Failed to load preferred relays from NIP-05: $e'); } } }