package com.labexposed.notas.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.labexposed.notas.data.local.NotasDatabase import com.labexposed.notas.data.local.NoteEntity import com.labexposed.notas.data.local.OutboxEntity import com.labexposed.notas.data.remote.ApiClient import com.labexposed.notas.data.remote.ApiResult import com.labexposed.notas.domain.Settings import com.labexposed.notas.domain.SettingsRepository import com.labexposed.notas.domain.SyncStatusStore import com.labexposed.notas.domain.SyncUiState import com.labexposed.notas.sync.SyncScheduler import android.content.Context import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import com.labexposed.notas.data.remote.SseClient import com.labexposed.notas.data.remote.SseEvent import com.labexposed.notas.data.remote.toNoteEntity sealed class ListEvent { data class ConnectionOk(val notes: Int) : ListEvent() data class ConnectionFail(val message: String) : ListEvent() data class RemoteChange(val title: String) : ListEvent() } class NotasViewModel(app: Application) : AndroidViewModel(app) { private val db = NotasDatabase.get(app) private val settingsRepo = SettingsRepository(app) val settings: StateFlow = settingsRepo.flow .stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) val notes: StateFlow> = db.notes().observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val categories: StateFlow> = db.categories().observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val syncState: StateFlow = combine( SyncStatusStore.state, db.outbox().observeCount(), db.notes().observePending(), ) { state, outbox, dirty -> state.copy(pendingCount = outbox, dirtyCount = dirty) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SyncUiState.Initial) private val _events = MutableStateFlow(null) val events: StateFlow = _events /** id de sessão estável por instalação (mesmo valor usado no presence/SSE). */ val sid: String = com.labexposed.notas.domain.SessionId.get(getApplication()) private var sseJob: Job? = null private var sseApi: ApiClient? = null /** noteId → nº de outras sessões com a nota aberta (presença). */ val presence = MutableStateFlow>(emptyMap()) /** notas sujas que mudaram remotamente (banner no editor). */ val remoteChanged = MutableStateFlow>(emptySet()) /** id da nota atualmente aberta no editor (para refresh ao vivo via SSE). */ @Volatile var openNoteId: String? = null /** conteúdo novo para o editor aberto (consumido e limpo pela UI). */ val editorRefresh = MutableStateFlow?>(null) fun onResumed() { viewModelScope.launch { val s = settingsRepo.current() if (!s.configured) return@launch if (sseJob?.isActive != true) { val client = SseClient(s.serverUrl, s.token, sid) sseApi = ApiClient(s.serverUrl, s.token) sseJob = client.connect(viewModelScope) { ev -> handleSse(ev) } } SyncScheduler.runNow(getApplication(), "resume") } } fun onPaused() { sseJob?.cancel() sseJob = null } private fun handleSse(ev: SseEvent) = viewModelScope.launch(Dispatchers.IO) { when (ev) { is SseEvent.Presence -> { val map = mutableMapOf() for (s in ev.sessions) { if (s.session == sid) continue for (n in s.notes ?: emptyList()) map[n] = (map[n] ?: 0) + 1 } presence.value = map } is SseEvent.Saved -> { if (ev.session == sid) return@launch val note = db.notes().byId(ev.noteId) ?: return@launch if (ev.deleted) { if (!note.dirty) { db.notes().delete(ev.noteId) db.outbox().dropFor(ev.noteId) _events.value = ListEvent.RemoteChange(ev.title) } return@launch } if (note.serverHash == ev.hash) return@launch if (!note.dirty) { val detail = sseApi?.getNote(ev.noteId) if (detail is ApiResult.Ok) { db.notes().upsert(detail.value.toNoteEntity()) _events.value = ListEvent.RemoteChange(ev.title) if (openNoteId == ev.noteId || db.meta().get("rekey:$openNoteId") == ev.noteId) { openNoteId?.let { editorRefresh.value = it to detail.value.content } } } } else { remoteChanged.value = remoteChanged.value + ev.noteId } } } } /** Nota abriu e mudou remotamente: recarrega do servidor sob demanda. */ fun reloadFromServer(id: String, then: ((NoteEntity?) -> Unit)? = null) = viewModelScope.launch(Dispatchers.IO) { val detail = sseApi?.getNote(id) if (detail is ApiResult.Ok) { db.notes().upsert(detail.value.toNoteEntity()) remoteChanged.value = remoteChanged.value - id } then?.invoke(db.notes().byId(id)) } fun dismissRemoteChange(id: String) { remoteChanged.value = remoteChanged.value - id } fun renderMarkdown(content: String, onResult: (String) -> Unit) = viewModelScope.launch(Dispatchers.IO) { val html = sseApi?.renderMd(content) if (html is ApiResult.Ok) onResult(html.value) else onResult("

…sem conexão para renderizar…

") } fun saveSettings(s: Settings) = viewModelScope.launch { settingsRepo.save(s) sseApi = if (s.configured) ApiClient(s.serverUrl, s.token) else null onPaused(); onResumed() SyncScheduler.runNow(getApplication(), "config") } fun testConnection(s: Settings) = viewModelScope.launch(Dispatchers.IO) { val result = ApiClient(s.serverUrl, s.token).testConnection() _events.value = when (result) { is ApiResult.Ok -> ListEvent.ConnectionOk(result.value.size) is ApiResult.Unauthorized -> ListEvent.ConnectionFail("token inválido (401)") is ApiResult.Network -> ListEvent.ConnectionFail("sem conexão: ${result.message}") else -> ListEvent.ConnectionFail(result.toString()) } } fun consumeEvent() { _events.value = null } fun syncNow() { SyncScheduler.runNow(getApplication(), "manual") } fun createLocal(): String { val id = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12) viewModelScope.launch(Dispatchers.IO) { val now = System.currentTimeMillis() db.notes().upsert( NoteEntity( id = id, title = "", format = "md", content = "", createdAt = now, updatedAt = now, dirty = true, ), ) // exatamente um create na fila; autosaves seguintes não re-enfileiram if (!db.outbox().has(id, "create")) { db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now)) } } return id } /** Nota por id, seguindo rekeys de create (id local → id do servidor). */ private suspend fun resolve(id: String): String? { if (db.notes().byId(id) != null) return id val mapped = db.meta().get("rekey:$id") ?: return null return if (db.notes().byId(mapped) != null) mapped else null } /** Salva edição local (conteúdo OU metadados) + enfileira + agenda sync. */ suspend fun saveNoteSync( id: String, title: String, format: String, categories: List, content: String, ) { val targetId = resolve(id) ?: return val existing = db.notes().byId(targetId) ?: return val now = System.currentTimeMillis() val contentChanged = existing.content != content val metaChanged = existing.title != title || existing.format != format || existing.categories != categories.joinToString(",") db.notes().upsert( existing.copy( id = targetId, title = title, format = format, categories = categories.joinToString(","), content = content, updatedAt = now, dirty = true, conflictNote = "", ), ) if (contentChanged || metaChanged) { // nota em create pendente já está na fila com o conteúdo atual (upsert acima) if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) { db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now)) } SyncScheduler.runNow(getApplication(), "autosave") } } fun saveNote( id: String, title: String, format: String, categories: List, content: String, ) = viewModelScope.launch(Dispatchers.IO) { saveNoteSync(id, title, format, categories, content) } fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) { val targetId = resolve(id) ?: return@launch val note = db.notes().byId(targetId) ?: return@launch if (note.isLocalOnly) { db.notes().delete(targetId) db.outbox().dropFor(targetId) } else { db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis())) db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "delete", queuedAt = System.currentTimeMillis())) SyncScheduler.runNow(getApplication(), "delete") } } suspend fun noteById(id: String): NoteEntity? = withContext(Dispatchers.IO) { db.notes().byId(id) } }