import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../local/local_storage_service.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 'models/user.dart'; /// Exception thrown when session operations fail. class SessionException implements Exception { /// Error message. final String message; /// Creates a [SessionException] with the provided message. SessionException(this.message); @override String toString() => 'SessionException: $message'; } /// 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 debugPrint('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) { debugPrint('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); // 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 debugPrint('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(); return user; } catch (e) { throw SessionException('Failed to login with Nostr: $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 debugPrint('Warning: Failed to sync to Firebase on logout: $e'); } } // Clear user-specific data if requested if (clearCache) { 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, ); } 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 if (_currentUser?.id == userId) { await _localStorage.clearAllData(); } // 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'); } } /// 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) { debugPrint('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. Future _loadPreferredRelaysIfAvailable() async { if (_currentUser == null || _nostrService == null) { return; } final profile = _currentUser!.nostrProfile; if (profile == null || profile.nip05 == null || profile.nip05!.isEmpty) { return; } try { final nip05 = profile.nip05!; final publicKey = _currentUser!.id; // User ID is the public key for Nostr users final addedCount = await _nostrService!.loadPreferredRelaysFromNip05( nip05, publicKey, ); if (addedCount > 0) { debugPrint('Loaded $addedCount preferred relay(s) from NIP-05: $nip05'); } } catch (e) { // Log error but don't fail - offline-first behavior debugPrint('Warning: Failed to load preferred relays from NIP-05: $e'); } } }