package com.shaarit.presentation.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shaarit.data.metadata.LinkMetadataExtractor import com.shaarit.domain.model.AiEnrichmentResult import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.repository.AddLinkResult import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.usecase.AnalyzeUrlWithAiUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.async import kotlinx.coroutines.launch import java.net.URLDecoder import javax.inject.Inject @HiltViewModel class AddLinkViewModel @Inject constructor( private val linkRepository: LinkRepository, private val metadataExtractor: LinkMetadataExtractor, private val analyzeUrlWithAiUseCase: AnalyzeUrlWithAiUseCase, savedStateHandle: SavedStateHandle ) : ViewModel() { // Pre-fill from usage arguments (e.g. from Share Intent via NavGraph) private val initialUrl: String? = savedStateHandle["url"] private val initialTitle: String? = savedStateHandle["title"] private val initialDescription: String? = savedStateHandle["description"] private val initialTags: String? = savedStateHandle["tags"] private val isFileShare: Boolean = savedStateHandle["isFileShare"] ?: false private val _uiState = MutableStateFlow(AddLinkUiState.Idle) val uiState = _uiState.asStateFlow() var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "") var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "") var description = MutableStateFlow(decodeUrlParam(initialDescription) ?: "") var isPrivate = MutableStateFlow(false) // Content type selection - default to NOTE for file shares private val _contentTypeSelection = MutableStateFlow(if (isFileShare) ContentType.NOTE else ContentType.BOOKMARK) val contentTypeSelection = _contentTypeSelection.asStateFlow() // Extraction state private val _isExtractingMetadata = MutableStateFlow(false) val isExtractingMetadata = _isExtractingMetadata.asStateFlow() private val _extractedThumbnail = MutableStateFlow(null) val extractedThumbnail = _extractedThumbnail.asStateFlow() private val _contentType = MutableStateFlow(null) val contentType = _contentType.asStateFlow() // New tag management private val _selectedTags = MutableStateFlow>(emptyList()) val selectedTags = _selectedTags.asStateFlow() private val _newTagInput = MutableStateFlow("") val newTagInput = _newTagInput.asStateFlow() private val _availableTags = MutableStateFlow>(emptyList()) val availableTags = _availableTags.asStateFlow() private val _tagSuggestions = MutableStateFlow>(emptyList()) val tagSuggestions = _tagSuggestions.asStateFlow() private val _aiEnrichmentState = MutableStateFlow(AiEnrichmentState.Idle) val aiEnrichmentState = _aiEnrichmentState.asStateFlow() private val _aiErrorMessage = MutableSharedFlow() val aiErrorMessage = _aiErrorMessage.asSharedFlow() // For conflict handling private var conflictLinkId: Int? = null init { loadAvailableTags() setupUrlMetadataExtraction() // Handle file share - add initial tags if (isFileShare) { // Parse and add initial tags from file share initialTags?.let { tagsParam -> val decodedTags = decodeUrlParam(tagsParam) decodedTags?.split(",")?.forEach { tag -> val cleanTag = tag.trim().lowercase() if (cleanTag.isNotBlank()) { _selectedTags.value = _selectedTags.value + cleanTag } } } } // Si une URL initiale est fournie, extraire les métadonnées if (!initialUrl.isNullOrBlank() && !isFileShare) { extractMetadata(initialUrl) } } @OptIn(FlowPreview::class) private fun setupUrlMetadataExtraction() { url .debounce(1000) // Attendre 1s après la fin de la saisie .onEach { urlString -> if (urlString.isNotBlank() && urlString.startsWith("http")) { extractMetadata(urlString) } } .launchIn(viewModelScope) } private fun extractMetadata(urlString: String) { viewModelScope.launch { _isExtractingMetadata.value = true // Launch JSoup metadata extraction and AI analysis in parallel val metadataDeferred = async { try { metadataExtractor.extract(urlString) } catch (e: Exception) { null } } val aiDeferred = if (analyzeUrlWithAiUseCase.isApiKeyConfigured() && _aiEnrichmentState.value != AiEnrichmentState.Loading) { _aiEnrichmentState.value = AiEnrichmentState.Loading async { analyzeUrlWithAiUseCase(urlString) } } else null // Apply JSoup metadata as soon as it arrives val metadata = metadataDeferred.await() if (metadata != null) { if (title.value.isBlank() && !metadata.title.isNullOrBlank()) { title.value = metadata.title } if (description.value.isBlank() && !metadata.description.isNullOrBlank()) { description.value = metadata.description } _extractedThumbnail.value = metadata.thumbnailUrl _contentType.value = metadata.contentType.name metadata.siteName?.let { site -> suggestTagFromSite(site) } } _isExtractingMetadata.value = false // Complete with AI enrichment aiDeferred?.await() ?.onSuccess { result -> applyAiEnrichment(result) _aiEnrichmentState.value = AiEnrichmentState.Success } ?.onFailure { _aiEnrichmentState.value = AiEnrichmentState.Idle } } } private fun suggestTagFromSite(siteName: String) { val siteTag = siteName.lowercase().replace(" ", "-").replace(".", "") if (siteTag !in _selectedTags.value) { // Ajouter automatiquement certains tags connus when (siteTag) { "youtube", "vimeo" -> addTag("video") "github", "gitlab" -> addTag("dev") "twitter", "x" -> addTag("social") "reddit" -> addTag("reddit") "medium", "devto" -> addTag("article") } } } /** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */ private fun decodeUrlParam(param: String?): String? { if (param.isNullOrBlank()) return null return try { URLDecoder.decode(param, "UTF-8").replace("+", " ").trim() } catch (e: Exception) { param.replace("+", " ").trim() } } private fun loadAvailableTags() { viewModelScope.launch { linkRepository .getTags() .fold( onSuccess = { tags -> _availableTags.value = tags.sortedByDescending { it.occurrences } }, onFailure = { // Silently fail - tags are optional } ) } } fun onNewTagInputChanged(input: String) { _newTagInput.value = input updateTagSuggestions(input) } private fun updateTagSuggestions(query: String) { if (query.isBlank()) { _tagSuggestions.value = emptyList() return } val queryLower = query.lowercase() _tagSuggestions.value = _availableTags .value .filter { it.name.lowercase().contains(queryLower) && it.name !in _selectedTags.value } .take(10) } fun addTag(tag: String) { val cleanTag = tag.trim().lowercase() if (cleanTag.isNotBlank() && cleanTag !in _selectedTags.value) { _selectedTags.value = _selectedTags.value + cleanTag _newTagInput.value = "" _tagSuggestions.value = emptyList() } } fun addNewTag() { addTag(_newTagInput.value) } fun analyzeUrlWithAi() { val currentUrl = url.value if (currentUrl.isBlank()) { viewModelScope.launch { _aiErrorMessage.emit("Veuillez d'abord entrer une URL") } return } if (!analyzeUrlWithAiUseCase.isApiKeyConfigured()) { viewModelScope.launch { _aiErrorMessage.emit("Clé API Gemini non configurée. Allez dans Paramètres.") } return } viewModelScope.launch { _aiEnrichmentState.value = AiEnrichmentState.Loading analyzeUrlWithAiUseCase(currentUrl) .onSuccess { result -> applyAiEnrichment(result) _aiEnrichmentState.value = AiEnrichmentState.Success } .onFailure { error -> _aiEnrichmentState.value = AiEnrichmentState.Idle _aiErrorMessage.emit(error.message ?: "Erreur lors de l'analyse IA") } } } private fun applyAiEnrichment(result: AiEnrichmentResult) { title.value = result.title description.value = result.description // Add AI-generated tags to existing tags (without duplicates) val currentTags = _selectedTags.value.toMutableSet() result.tags.forEach { tag -> val cleanTag = tag.trim().lowercase() if (cleanTag.isNotBlank()) { currentTags.add(cleanTag) } } _selectedTags.value = currentTags.toList() } fun resetAiEnrichmentState() { _aiEnrichmentState.value = AiEnrichmentState.Idle } fun removeTag(tag: String) { _selectedTags.value = _selectedTags.value - tag } fun addLink() { viewModelScope.launch { _uiState.value = AddLinkUiState.Loading val currentUrl = url.value val currentTitle = title.value // Validation based on content type when (_contentTypeSelection.value) { ContentType.BOOKMARK -> { if (currentUrl.isBlank()) { _uiState.value = AddLinkUiState.Error("URL is required for bookmarks") return@launch } } ContentType.NOTE -> { if (currentTitle.isBlank()) { _uiState.value = AddLinkUiState.Error("Title is required for notes") return@launch } } } var finalUrl = currentUrl var finalTitle = title.value if (_contentTypeSelection.value == ContentType.NOTE) { if (finalUrl.isBlank()) { finalUrl = "https://shaarit.app/note/${generateRandomId()}" } if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) { finalTitle = "Note: $finalTitle" } } val result = linkRepository.addOrUpdateLink( url = finalUrl, title = finalTitle.ifBlank { null }, description = description.value.ifBlank { null }, tags = _selectedTags.value.ifEmpty { null }, isPrivate = isPrivate.value, forceUpdate = false, existingLinkId = null ) when (result) { is AddLinkResult.Success -> { _uiState.value = AddLinkUiState.Success } is AddLinkResult.Conflict -> { conflictLinkId = result.existingLinkId _uiState.value = AddLinkUiState.Conflict( result.existingLinkId, result.existingTitle ) } is AddLinkResult.Error -> { _uiState.value = AddLinkUiState.Error(result.message) } } } } fun forceUpdateExistingLink() { viewModelScope.launch { val currentUrl = url.value val currentTitle = title.value // Validation based on content type when (_contentTypeSelection.value) { ContentType.BOOKMARK -> { if (currentUrl.isBlank()) { _uiState.value = AddLinkUiState.Error("URL is required for bookmarks") return@launch } } ContentType.NOTE -> { if (currentTitle.isBlank()) { _uiState.value = AddLinkUiState.Error("Title is required for notes") return@launch } } } _uiState.value = AddLinkUiState.Loading var finalUrl = currentUrl var finalTitle = title.value if (_contentTypeSelection.value == ContentType.NOTE) { if (finalUrl.isBlank()) { finalUrl = "https://shaarit.app/note/${generateRandomId()}" } if (finalTitle.isNotBlank() && !finalTitle.startsWith("Note: ", ignoreCase = true)) { finalTitle = "Note: $finalTitle" } } val result = linkRepository.addOrUpdateLink( url = finalUrl, title = finalTitle.ifBlank { null }, description = description.value.ifBlank { null }, tags = _selectedTags.value.ifEmpty { null }, isPrivate = isPrivate.value, forceUpdate = true, existingLinkId = conflictLinkId ) when (result) { is AddLinkResult.Success -> { _uiState.value = AddLinkUiState.Success } is AddLinkResult.Error -> { _uiState.value = AddLinkUiState.Error(result.message) } else -> { _uiState.value = AddLinkUiState.Error("Unexpected result") } } } } fun dismissConflict() { _uiState.value = AddLinkUiState.Idle conflictLinkId = null } fun setContentType(type: ContentType) { _contentTypeSelection.value = type // Auto-add "note" tag when Note type is selected if (type == ContentType.NOTE && "note" !in _selectedTags.value) { addTag("note") } else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) { // Remove "note" tag when switching back to Bookmark removeTag("note") } } private fun generateRandomId(length: Int = 6): String { val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" return (1..length) .map { chars.random() } .joinToString("") } } sealed class AddLinkUiState { object Idle : AddLinkUiState() object Loading : AddLinkUiState() object Success : AddLinkUiState() data class Error(val message: String) : AddLinkUiState() data class Conflict(val existingLinkId: Int, val existingTitle: String?) : AddLinkUiState() } enum class ContentType { BOOKMARK, NOTE } sealed class AiEnrichmentState { object Idle : AiEnrichmentState() object Loading : AiEnrichmentState() object Success : AiEnrichmentState() }