# Central ProfileManager — Active Profile Tracking + CRUD

## Problem

NexStream's profile system had Room DAO + PIN hashing + settings screens, but lacked a central `ProfileManager` to:
- Track `activeProfileId` as a `StateFlow`
- Enforce max 5 profiles
- Handle per-profile DataStore isolation
- Clean up profile data on delete
- Auto-switch to primary profile when the active profile is deleted

## Solution: ProfileManager

```kotlin
@Singleton
class ProfileManager @Inject constructor(
    private val profileDao: ProfileDao,
    private val profileDataStoreFactory: ProfileDataStoreFactory,
    @ApplicationContext private val context: Context,
) {
    companion object {
        const val MAX_PROFILES = 5
        private const val PREFS_FILE = "profile_preferences"
        private const val KEY_ACTIVE_PROFILE_ID = "active_profile_id"
        private const val KEY_REMEMBER_LAST_PROFILE = "remember_last_profile"
    }

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    // ── Active Profile Tracking ──

    private val prefsFlow = MutableStateFlow<Map<String, Any?>>(emptyMap())

    val activeProfileId: StateFlow<Int> = prefsFlow
        .map { it[KEY_ACTIVE_PROFILE_ID] as? Int ?: 1 }
        .stateIn(scope, SharingStarted.Eagerly, 1)

    val hasEverSelectedProfile: StateFlow<Boolean> = prefsFlow
        .map { it.containsKey(KEY_ACTIVE_PROFILE_ID) }
        .stateIn(scope, SharingStarted.Eagerly, false)

    val rememberLastProfileEnabled: StateFlow<Boolean> = prefsFlow
        .map { it[KEY_REMEMBER_LAST_PROFILE] as? Boolean ?: true }
        .stateIn(scope, SharingStarted.Eagerly, true)

    val profiles: StateFlow<List<Profile>> = profileDao.observeAll()
        .map { entities -> entities.map { it.toDomain() } }
        .stateIn(scope, SharingStarted.Eagerly, emptyList())

    val activeProfile: Profile?
        get() = profiles.value.find { it.id == activeProfileId.value }

    val isPrimaryProfileActive: Boolean
        get() = activeProfileId.value == 1

    val canCreateProfile: Boolean
        get() = profiles.value.size < MAX_PROFILES

    // ── Profile Selection ──

    fun setActiveProfile(id: Int) {
        scope.launch {
            val exists = profiles.value.any { it.id == id }
            if (exists) {
                setPreference(KEY_ACTIVE_PROFILE_ID, id)
            }
        }
    }

    fun setRememberLastProfileEnabled(enabled: Boolean) {
        setPreference(KEY_REMEMBER_LAST_PROFILE, enabled)
    }

    // ── Profile CRUD ──

    suspend fun createProfile(
        name: String,
        avatar: String = "\uD83D\uDC64",
        isKidsProfile: Boolean = false,
    ): Boolean = withContext(Dispatchers.IO) {
        val current = profiles.value
        if (current.size >= MAX_PROFILES) return@withContext false

        val usedIds = current.map { it.id }.toSet()
        val nextId = (1..MAX_PROFILES).firstOrNull { it !in usedIds } ?: return@withContext false

        val profile = Profile(
            id = nextId,
            name = name.trim().ifEmpty { "Profile $nextId" },
            avatar = avatar,
            isKidsProfile = isKidsProfile,
        )

        val entity = ProfileEntity.fromDomain(profile)
        profileDao.insert(entity)

        // Initialize per-profile DataStore
        profileDataStoreFactory.get(nextId, "settings")

        // Set as active if first profile
        if (current.isEmpty()) {
            setPreference(KEY_ACTIVE_PROFILE_ID, nextId)
        }

        true
    }

    suspend fun deleteProfile(id: Int): Boolean = withContext(Dispatchers.IO) {
        if (id == 1) return@withContext false // Never delete primary
        if (profiles.value.none { it.id == id }) return@withContext false

        deleteProfileDataAsync(id)
        profileDao.deleteById(id)

        // If deleted profile was active, switch to primary
        if (activeProfileId.value == id) {
            setPreference(KEY_ACTIVE_PROFILE_ID, 1)
        }

        true
    }

    suspend fun updateProfile(profile: Profile): Boolean = withContext(Dispatchers.IO) {
        if (profiles.value.none { it.id == profile.id }) return@withContext false

        val entity = ProfileEntity.fromDomain(profile)
        profileDao.update(entity)
        true
    }

    // ── Per-Profile DataStore ──

    fun getProfileDataStore(profileId: Int, featureName: String) =
        profileDataStoreFactory.get(profileId, featureName)

    // ── Internal ──

    private suspend fun deleteProfileDataAsync(profileId: Int) = withContext(Dispatchers.IO) {
        if (profileId == 1) return@withContext

        // Clear per-profile DataStore files
        profileDataStoreFactory.clearProfile(profileId)

        // Delete DataStore preference files
        val suffixWithExtension = "_p${profileId}.preferences_pb"
        val dataStoreDir = File(context.filesDir, "datastore")
        if (dataStoreDir.exists()) {
            dataStoreDir.listFiles()?.forEach { file ->
                if (file.name.endsWith(suffixWithExtension)) {
                    file.delete()
                }
            }
        }

        // Delete plugin code directory
        val pluginCodeDir = File(context.filesDir, "plugin_code_p${profileId}")
        if (pluginCodeDir.exists()) {
            pluginCodeDir.deleteRecursively()
        }
    }

    private fun setPreference(key: String, value: Any?) {
        val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
        when (value) {
            is Int -> prefs.edit().putInt(key, value).apply()
            is Boolean -> prefs.edit().putBoolean(key, value).apply()
            is String -> prefs.edit().putString(key, value).apply()
            else -> prefs.edit().remove(key).apply()
        }
    }
}
```

## Integration with ProfileViewModel

Replace direct `PreferencesManager` usage with `ProfileManager`:

```kotlin
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val profileManager: ProfileManager,
    private val profileDao: ProfileDao,
) : ViewModel() {

    val profileListState: StateFlow<ProfileListState> = profileManager.profiles
        .map { profiles -> ProfileListState(profiles = profiles, isLoading = false) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileListState(isLoading = true))

    fun setActiveProfile(profileId: Int) {
        profileManager.setActiveProfile(profileId) // delegates to scope.launch internally
    }

    fun deleteProfile(profileId: Int) {
        viewModelScope.launch {
            profileDao.deleteById(profileId)
            if (profileManager.activeProfileId.value == profileId) {
                profileManager.setActiveProfile(1) // auto-switch to primary
            }
        }
    }

    suspend fun requiresPin(profileId: Int): Boolean {
        val entity = profileDao.getById(profileId) ?: return false
        return !entity.pinHash.isNullOrBlank() && !entity.pinSalt.isNullOrBlank()
    }

    // ... CRUD methods delegate to profileManager or profileDao
}
```

## Integration with DetailsViewModel

Use `ProfileManager.activeProfileId` instead of `PreferencesManager.activeProfileId`:

```kotlin
@HiltViewModel
class DetailsViewModel @Inject constructor(
    private val profileManager: ProfileManager,
    private val preferencesManager: PreferencesManager, // still needed for sortPreset, etc.
    // ... other deps
) : ViewModel() {

    // Use .value (not .first()) inside viewModelScope.launch blocks
    val profileId = profileManager.activeProfileId.value
    // Use .first() only in suspend functions
}
```

## Pitfalls

1. **`viewModelScope` in non-ViewModel classes**: `ProfileManager` is a `@Singleton`, NOT a `ViewModel`. It has NO `viewModelScope`. Use a custom `CoroutineScope(SupervisorJob() + Dispatchers.IO)` instead. Calling `viewModelScope.launch` from a `@Singleton` produces a compile error. **Dead-code trap**: Do NOT add a `private fun viewModelScope.launch(...)` helper to bridge the gap — it shadows the ViewModel's scope and creates confusion. Delete any such helper if found.
2. **`.value` vs `.first()` for StateFlow reads**: Inside `viewModelScope.launch` blocks, use `profileManager.activeProfileId.value` (hot StateFlow, no suspension needed). Use `.first()` only in `suspend` functions or when you need a cold read. Mixing them up causes unnecessary suspension or stale reads.
3. **`prefsFlow` initialization**: `MutableStateFlow(emptyMap())` starts empty. The `activeProfileId` flow defaults to `1` (primary profile) when no preference is set. This is correct — the primary profile always exists.
4. **`setPreference` uses SharedPreferences, NOT DataStore**: For simplicity, profile selection is stored in SharedPreferences. Per-profile settings use DataStore via `ProfileDataStoreFactory`. This separation is intentional — profile selection is a simple key-value, while per-profile settings need isolation.
5. **`deleteProfileDataAsync` cleanup**: Deletes both DataStore files and plugin_code directories. The suffix pattern `_p${profileId}.preferences_pb` matches the `ProfileDataStoreFactory` naming convention.
6. **Max 5 profiles**: Enforced in `createProfile()`. IDs are assigned sequentially (1..5), skipping used IDs. This matches NuvioTV's convention.
7. **Primary profile (id=1) cannot be deleted**: `deleteProfile()` returns `false` for id=1. The primary profile is the fallback when no other profile is active.
8. **Auto-switch on delete**: When the active profile is deleted, `setPreference(KEY_ACTIVE_PROFILE_ID, 1)` switches to the primary profile. This prevents the app from being in a "no active profile" state.
9. **ViewModels may still need `PreferencesManager`**: `ProfileManager` replaces `PreferencesManager` only for active-profile tracking. Other `PreferencesManager` features (sortPreset, etc.) still require injecting `PreferencesManager` alongside `ProfileManager`.

10. **`.value` vs `.first()` access pattern**: Inside `viewModelScope.launch` blocks, use `profileManager.activeProfileId.value` (hot StateFlow, no suspension needed). Use `.first()` only in `suspend` functions or when you need a cold read. Mixing them up causes unnecessary suspension or stale reads. This was the pattern used when wiring `ProfileManager` into `DetailsViewModel` — two call sites changed from `preferencesManager.activeProfileId.first()` to `profileManager.activeProfileId.value`.

11. **Dead code trap — `viewModelScope.launch` helper in `@Singleton`**: Do NOT add a `private fun viewModelScope.launch(...)` helper in a `@Singleton` class to bridge the gap. It shadows the ViewModel's scope, creates confusion, and is never called (the class uses its own `scope.launch`). If found, delete it — it's dead code.
