package top.yeij.cyrene.data.repository

import android.app.Application
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.local.dao.ConversationDao
import top.yeij.cyrene.data.local.dao.MessageDao
import top.yeij.cyrene.data.local.entity.ConversationEntity
import top.yeij.cyrene.data.local.entity.MessageEntity
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.KeepAliveReceiver
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID

class ChatRepositoryImpl(
    private val app: Application,
    private val conversationDao: ConversationDao,
    private val messageDao: MessageDao,
    private val webSocketService: WebSocketService,
    private val apiService: ApiService,
    private val preferencesDataStore: PreferencesDataStore,
    private val notificationHelper: NotificationHelper,
) : ChatRepository {

    private val exceptionHandler = CoroutineExceptionHandler { _, e ->
        Log.e("ChatRepository", "Unhandled exception", e)
    }
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)

    private val _connectionState = MutableStateFlow(false)
    override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()

    override val connectionError: StateFlow<String?> = webSocketService.connectionError

    private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
    override fun observeMessages(): Flow<Message> = _incomingMessages

    private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
    override val messageClearEvents: Flow<Unit> = _messageClearEvents

    private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
    override val messageRemovals: Flow<String> = _messageRemovals

    private val _isAssistantStreaming = MutableStateFlow(false)
    override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()

    private var streamingContent = ""
    private var streamingMessageId: String? = null
    private var streamTimeoutJob: kotlinx.coroutines.Job? = null
    override var currentSessionId: String? = null

    private var isAppInForeground = false
    private var hasEverBeenForeground = false
    private var historyRequested = false
    private val notifiedMessageIds = mutableSetOf<String>()

    // Duplicate suppression: track items from review/multi_message to skip wrapping response
    private val recentParsedContents = mutableListOf<String>()
    private var lastParsedTime = 0L
    // Track last response to clean up if review/multi_message arrives after
    private var lastResponseId: String? = null
    private var lastResponseContent: String? = null
    private var lastResponseTime = 0L

    fun cancelNotifications() {
        notificationHelper.cancelAll()
    }

    private fun resetStreamTimeout() {
        cancelStreamTimeout()
        streamTimeoutJob = scope.launch {
            kotlinx.coroutines.delay(120_000L) // 2 min timeout
            if (_isAssistantStreaming.value) {
                RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting")
                streamingContent = ""
                streamingMessageId = null
                _isAssistantStreaming.value = false
                emitMessage(
                    id = "timeout_${System.currentTimeMillis()}",
                    sessionId = currentSessionId ?: "default",
                    role = "system",
                    content = "AI 响应超时，请重试",
                    msgType = "system_info",
                    isStreaming = false,
                )
            }
        }
    }

    private fun cancelStreamTimeout() {
        streamTimeoutJob?.cancel()
        streamTimeoutJob = null
    }

    override fun onAppForeground() {
        RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
        isAppInForeground = true
        hasEverBeenForeground = true
        notifiedMessageIds.clear()
        notificationHelper.cancelAll()
        KeepAliveReceiver.cancel(app)
        WebSocketKeepAliveService.stop(app)
        RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
        scope.launch {
            val sid = currentSessionId ?: return@launch
            RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
            requestHistoryViaWs(sid)
        }
    }

    override fun onAppBackground() {
        isAppInForeground = false
        WebSocketKeepAliveService.start(app)
        KeepAliveReceiver.schedule(app)
        val currentlyConnected = _connectionState.value
        RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
        // Only reconnect if the WS is already dead. Tearing down a healthy
        // connection creates a message loss window with no benefit.
        if (!currentlyConnected) {
            scope.launch {
                kotlinx.coroutines.delay(1500) // let the service start first
                webSocketService.forceReconnect()
                RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
            }
        } else {
            RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
        }
    }

    init {
        // Restore persisted session ID, then connect and load history
        scope.launch {
            val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
            if (!persistedSid.isNullOrBlank()) {
                currentSessionId = persistedSid
            }
            RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
            webSocketService.connect(currentSessionId)
            loadConversationsFromServer()
        }
        scope.launch {
            webSocketService.isConnected.collect { connected ->
                _connectionState.value = connected
                RuntimeLog.ws("connection", "Connected=$connected")
            }
        }
        scope.launch {
            webSocketService.incomingMessages.collect { wsMsg ->
                try {
                    RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
                    handleServerMessage(wsMsg)
                } catch (e: Exception) {
                    Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
                    RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
                }
            }
        }
    }

    override fun getConversations(): Flow<List<Conversation>> {
        return conversationDao.getAll().map { entities ->
            entities.map { it.toDomain() }
        }
    }

    override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
        return messageDao.getByConversation(conversationId).map { entities ->
            entities.map { it.toDomain() }
        }
    }

    override suspend fun deleteConversation(id: String) {
        conversationDao.deleteById(id)
        try { apiService.deleteSession(id) } catch (_: Exception) { }
    }

    override suspend fun clearLocalMessages() {
        val now = System.currentTimeMillis()
        messageDao.deleteAll()
        preferencesDataStore.saveLastClearedTimestamp(now)

        _messageClearEvents.tryEmit(Unit)

        RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
        Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
    }

    private suspend fun changeSessionId(newId: String) {
        val oldId = currentSessionId
        if (oldId != null && oldId != newId) {
            messageDao.migrateConversationId(oldId, newId)
            conversationDao.deleteById(oldId)
        }
        currentSessionId = newId
        preferencesDataStore.saveCurrentSessionId(newId)
    }

    override suspend fun connectWebSocket(sessionId: String?) {
        currentSessionId = sessionId
        webSocketService.connect(sessionId)
    }

    override suspend fun reconnectWebSocket() {
        webSocketService.disconnect()
        webSocketService.connect(currentSessionId)
    }

    override suspend fun ensureConnected() {
        // Always force reconnect — connectionState may be stuck at true on a silently dead socket
        webSocketService.forceReconnect()
    }

    override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
        val messageId = UUID.randomUUID().toString()
        val now = System.currentTimeMillis()
        val sid = sessionId ?: currentSessionId ?: "default"
        if (currentSessionId == null) {
            currentSessionId = sid
            scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
        }

        val hasImages = localImageUris.isNotEmpty()
        val displayContent = content.ifBlank { "" }
        val lastMsg = when {
            hasImages && content.isBlank() -> "[图片]"
            hasImages -> content
            else -> content
        }

        RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)} attachments=${attachments?.size ?: 0}")

        conversationDao.upsert(
            ConversationEntity(
                id = sid,
                title = "对话",
                lastMessage = lastMsg,
                lastMessageType = "chat",
                updatedAt = now,
                createdAt = now,
            )
        )

        messageDao.upsert(
            MessageEntity(
                id = messageId,
                conversationId = sid,
                role = "user",
                content = displayContent,
                msgType = "chat",
                timestamp = now,
            )
        )

        emitMessage(
            id = messageId,
            sessionId = sid,
            role = "user",
            content = displayContent,
            msgType = "chat",
            timestamp = now,
            isStreaming = false,
            imageDataUris = localImageUris,
        )

        webSocketService.sendMessage(content, sid, attachments = attachments)
    }

    override suspend fun loadConversationsFromServer() {
        try {
            val response = apiService.getSessions()
            if (response.isSuccessful) {
                response.body()?.sessions?.forEach { dto ->
                    conversationDao.upsert(
                        ConversationEntity(
                            id = dto.id,
                            title = dto.title,
                            lastMessage = "",
                            lastMessageType = "chat",
                            updatedAt = dto.updatedAt,
                            createdAt = dto.createdAt,
                        )
                    )
                }
            }
        } catch (_: Exception) { }
    }

    override suspend fun sendScreenContext(content: String) {
        webSocketService.sendScreenContext(content, currentSessionId)
    }

    override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
        webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
    }

    override suspend fun initializeSession(): String {
        // Try to find an existing main session on the server
        try {
            val response = apiService.getSessions()
            if (response.isSuccessful) {
                val sessions = response.body()?.sessions ?: emptyList()
                val mainSession = sessions.find { it.isMain == true }
                if (mainSession != null) {
                    currentSessionId = mainSession.id
                    preferencesDataStore.saveCurrentSessionId(mainSession.id)
                    Log.i("ChatRepository", "Found main session: ${mainSession.id}")
                    return mainSession.id
                }
            }
        } catch (_: Exception) { }

        // No main session found on server, create one with deterministic ID
        val sessionId = "session_admin_main"
        try {
            apiService.createSession(
                CreateSessionRequest(
                    sessionId = sessionId,
                    title = "主对话",
                    isMain = true,
                )
            )
            Log.i("ChatRepository", "Created main session: $sessionId")
        } catch (e: Exception) {
            Log.w("ChatRepository", "Failed to create main session: ${e.message}")
        }

        currentSessionId = sessionId
        preferencesDataStore.saveCurrentSessionId(sessionId)
        return sessionId
    }

    override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
        currentSessionId = sessionId
        return try {
            val response = apiService.getSessionMessages(sessionId)
            if (response.isSuccessful) {
                val messageDtos = response.body()?.messages ?: emptyList()
                val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
                    ?.toLongOrNull() ?: 0L
                val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
                ensureConversation(sessionId)
                val messages = filteredDtos.map { dto ->
                    Message(
                        id = "${dto.id}",
                        conversationId = sessionId,
                        role = dto.role,
                        content = dto.content,
                        msgType = dto.msgType ?: "chat",
                        timestamp = dto.createdAt,
                    )
                }
                val deduped = messages.removeWrappingDuplicates().splitInlineActions()
                messageDao.deleteUserMessagesByConversation(sessionId)
                messageDao.upsertAll(deduped.map { msg ->
                    MessageEntity(
                        id = msg.id,
                        conversationId = msg.conversationId,
                        role = msg.role,
                        content = msg.content,
                        msgType = msg.msgType,
                        timestamp = msg.timestamp,
                    )
                })
                RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
                deduped
            } else {
                RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
                requestHistoryViaWs(sessionId)
                emptyList()
            }
        } catch (e: Exception) {
            RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
            requestHistoryViaWs(sessionId)
            emptyList()
        }
    }

    private suspend fun requestHistoryViaWs(sessionId: String) {
        if (!webSocketService.isConnected.value) {
            val connected = withTimeoutOrNull(5000) {
                webSocketService.isConnected.first { it }
            }
            if (connected != true) {
                // WS couldn't connect, fall back to REST API
                RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
                loadMessagesFromServer(sessionId)
                return
            }
        }
        webSocketService.requestHistory(sessionId)
    }

    private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
        val existing = conversationDao.getById(sessionId)
        if (existing == null) {
            val now = System.currentTimeMillis()
            conversationDao.upsert(
                ConversationEntity(
                    id = sessionId,
                    title = "对话",
                    lastMessage = lastMessage,
                    lastMessageType = "chat",
                    updatedAt = now,
                    createdAt = now,
                )
            )
        }
    }

    private suspend fun handleServerMessage(wsMsg: WSServerMessage) {
        when (wsMsg.type) {
            "stream_start" -> {
                streamingContent = ""
                streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
                _isAssistantStreaming.value = true
                recentParsedContents.clear()
                resetStreamTimeout()
                RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
            }

            "stream_chunk" -> {
                val delta = wsMsg.content ?: wsMsg.text ?: return
                streamingContent += delta
                resetStreamTimeout()
                emitMessage(
                    id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
                    sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
                    role = "assistant",
                    content = streamingContent,
                    msgType = wsMsg.msgType ?: "chat",
                    isStreaming = true,
                )
            }

            "stream_end" -> {
                cancelStreamTimeout()
                val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
                val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
                streamingContent = ""
                streamingMessageId = null
                val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
                if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
                    changeSessionId(sid)
                }
                val ts = wsMsg.timestamp ?: System.currentTimeMillis()

                // Dedup: suppress if streaming content wraps already-shown multi_message/review items
                val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
                if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
                    val allContained = recentParsedContents.all { content.contains(it) }
                    if (allContained) {
                        RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
                        recentParsedContents.clear()
                        _isAssistantStreaming.value = false
                        return
                    }
                }
                recentParsedContents.clear()

                if (content.isNotBlank()) {
                    ensureConversation(sid, content)
                    messageDao.upsert(
                        MessageEntity(
                            id = msgId,
                            conversationId = sid,
                            role = "assistant",
                            content = content,
                            msgType = wsMsg.msgType ?: "chat",
                            timestamp = ts,
                        )
                    )
                }

                lastResponseId = msgId
                lastResponseContent = content
                lastResponseTime = System.currentTimeMillis()

                RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
                emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
                _isAssistantStreaming.value = false
            }

            "response" -> {
                val text = wsMsg.content ?: wsMsg.text ?: return
                val role = wsMsg.role ?: "assistant"
                val replyMsgType = wsMsg.msgType ?: "chat"
                val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
                val sid = wsMsg.sessionId ?: currentSessionId ?: "default"

                if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
                    changeSessionId(sid)
                }
                val ts = wsMsg.timestamp ?: System.currentTimeMillis()

                ensureConversation(sid, text)
                messageDao.upsert(
                    MessageEntity(
                        id = msgId,
                        conversationId = sid,
                        role = role,
                        content = text,
                        msgType = replyMsgType,
                        timestamp = ts,
                    )
                )

                lastResponseId = msgId
                lastResponseContent = text
                lastResponseTime = System.currentTimeMillis()

                // Track parsed content so stream_end can suppress the wrapping full text
                recentParsedContents.add(text)
                lastParsedTime = System.currentTimeMillis()

                RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
                emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
            }

            "review" -> {
                recentParsedContents.clear()
                wsMsg.reviewMessages?.forEachIndexed { index, review ->
                    if (index > 0) delay(1000L)
                    val rawText = review.content ?: review.text ?: return@forEachIndexed
                    val role = review.role ?: "assistant"
                    val rvMsgType = review.type ?: review.msgType ?: "action"
                    val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
                    // Encode code language metadata into content for the renderer
                    val content = if (rvMsgType == "code" && review.metadata?.language != null) {
                        "[lang:${review.metadata.language}]\n$rawText"
                    } else rawText
                    recentParsedContents.add(rawText)
                    emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false)
                }
                if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
                // Clean up wrapping response that arrived before this review
                cleanupWrappingResponse()
            }

            "thinking" -> {
                val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text
                if (text != null) {
                    emitMessage(
                        id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}",
                        sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
                        role = "assistant",
                        content = text,
                        msgType = wsMsg.msgType ?: "thinking",
                        isStreaming = false,
                    )
                }
            }

            "tool_progress" -> {
                val detail = wsMsg.toolProgress?.message
                    ?: wsMsg.toolProgress?.detail
                    ?: wsMsg.toolProgress?.toolName
                    ?: "正在执行操作..."
                emitMessage(
                    id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}",
                    sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
                    role = "system",
                    content = detail,
                    msgType = wsMsg.msgType ?: "tool_progress",
                    isStreaming = false,
                )
            }

            "queued" -> {
                emitMessage(
                    id = "queued_${System.currentTimeMillis()}",
                    sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
                    role = "system",
                    content = "消息已加入处理队列",
                    msgType = "system_info",
                    isStreaming = false,
                )
            }

            "error" -> {
                cancelStreamTimeout()
                streamingContent = ""
                streamingMessageId = null
                _isAssistantStreaming.value = false
                RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
                emitMessage(
                    id = "err_${System.currentTimeMillis()}",
                    sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
                    role = "system",
                    content = wsMsg.error ?: "未知错误",
                    msgType = wsMsg.msgType ?: "system_info",
                    isStreaming = false,
                )
            }

            "voice_transcript" -> {
                val text = wsMsg.text ?: wsMsg.content ?: return
                val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
                val ts = wsMsg.timestamp ?: System.currentTimeMillis()
                val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
                ensureConversation(sid)
                messageDao.upsert(
                    MessageEntity(
                        id = msgId,
                        conversationId = sid,
                        role = "user",
                        content = text,
                        msgType = wsMsg.msgType ?: "chat",
                        timestamp = ts,
                    )
                )
                emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false)
            }

            "history_response" -> {
                val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
                if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
                    changeSessionId(sid)
                }
                ensureConversation(sid)
                val messages = wsMsg.messages ?: return
                val messageList = messages.map { hist ->
                    val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
                    Message(
                        id = msgId,
                        conversationId = sid,
                        role = hist.role ?: "system",
                        content = hist.content ?: "",
                        msgType = hist.msgType ?: "chat",
                        timestamp = hist.timestamp ?: System.currentTimeMillis(),
                    )
                }
                val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
                messageDao.upsertAll(deduped.map { msg ->
                    MessageEntity(
                        id = msg.id,
                        conversationId = msg.conversationId,
                        role = msg.role,
                        content = msg.content,
                        msgType = msg.msgType,
                        timestamp = msg.timestamp,
                    )
                })
                deduped.forEach { msg ->
                    emitMessage(
                        id = msg.id,
                        sessionId = msg.conversationId,
                        role = msg.role,
                        content = msg.content,
                        msgType = msg.msgType,
                        timestamp = msg.timestamp,
                        isStreaming = false,
                        shouldNotify = false,
                    )
                }
                RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
            }

            "multi_message" -> {
                recentParsedContents.clear()
                var isFirst = true
                wsMsg.multiMessages?.forEachIndexed { index, item ->
                    if (index > 0) delay(1000L)
                    val content = item.content ?: ""
                    recentParsedContents.add(content)
                    emitMessage(
                        id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
                        sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
                        role = item.role ?: "assistant",
                        content = content,
                        msgType = item.msgType ?: "chat",
                        timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
                        isStreaming = false,
                        shouldNotify = isFirst,
                    )
                    isFirst = false
                }
                if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
                cleanupWrappingResponse()
            }
        }
    }

    private suspend fun cleanupWrappingResponse() {
        val respId = lastResponseId ?: return
        val respContent = lastResponseContent ?: return
        val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
        if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
        val allContained = recentParsedContents.all { respContent.contains(it) }
        if (allContained) {
            messageDao.deleteById(respId)
            _messageRemovals.tryEmit(respId)
            RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state id=$respId")
        }
    }

    private fun emitMessage(
        id: String,
        sessionId: String,
        role: String,
        content: String,
        msgType: String,
        isStreaming: Boolean = false,
        timestamp: Long = System.currentTimeMillis(),
        shouldNotify: Boolean = false,
        imageDataUris: List<String> = emptyList(),
    ) {
        if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return

        // Fallback: detect inline <action> tags missed by server parsing
        if (role == "assistant" && msgType == "chat") {
            val actionRegex = Regex("""<action>(.*?)</action>\s*""")
            val match = actionRegex.find(content)
            if (match != null) {
                val actionText = match.groupValues[1].trim()
                val remaining = actionRegex.replaceFirst(content, "").trim()
                RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
                if (actionText.isNotEmpty()) {
                    emitMessage(
                        id = "${id}_action",
                        sessionId = sessionId,
                        role = "assistant",
                        content = actionText,
                        msgType = "action",
                        timestamp = timestamp,
                        shouldNotify = false,
                    )
                }
                if (remaining.isNotEmpty()) {
                    emitMessage(
                        id = id,
                        sessionId = sessionId,
                        role = role,
                        content = remaining,
                        msgType = msgType,
                        timestamp = timestamp + 1,
                        isStreaming = isStreaming,
                        shouldNotify = shouldNotify,
                    )
                }
                return
            }
        }

        val message = Message(
            id = id,
            conversationId = sessionId,
            role = role,
            content = content,
            msgType = msgType,
            timestamp = timestamp,
            isStreaming = isStreaming,
            imageDataUris = imageDataUris,
        )
        _incomingMessages.tryEmit(message)

        if (shouldNotify && role == "assistant" && !isStreaming) {
            if (!hasEverBeenForeground) {
                RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
            } else if (isAppInForeground) {
                RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
            } else {
                if (notifiedMessageIds.add(id)) {
                    notificationHelper.showMessageNotification(message)
                    RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
                } else {
                    RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
                }
            }
        } else if (shouldNotify && role == "assistant" && isStreaming) {
            RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
        }
    }

    /**
     * Remove wrapper messages whose content contains the content of 2+ other messages.
     * This handles the case where the server sends both a combined "response" and
     * parsed "review"/"multi_message" items for the same turn.
     */
    private fun List<Message>.removeWrappingDuplicates(): List<Message> {
        if (size < 3) return this
        val toRemove = mutableSetOf<String>()
        for (msg in this) {
            val containedCount = count { other ->
                other.id != msg.id &&
                    other.content.isNotBlank() &&
                    other.content.length < msg.content.length &&
                    msg.content.contains(other.content) &&
                    kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
            }
            if (containedCount >= 2) {
                toRemove.add(msg.id)
            }
        }
        return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
    }

    private fun ConversationEntity.toDomain() = Conversation(
        id = id,
        title = title,
        lastMessage = lastMessage,
        lastMessageType = lastMessageType,
        updatedAt = updatedAt,
        createdAt = createdAt,
    )

    /**
     * Split inline `<action>` tags from assistant chat messages into separate messages.
     * Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
     */
    private fun List<Message>.splitInlineActions(): List<Message> {
        val actionRegex = Regex("""<action>(.*?)</action>\s*""")
        return flatMap { msg ->
            if (msg.role == "assistant" && msg.msgType == "chat") {
                val match = actionRegex.find(msg.content)
                if (match != null) {
                    val actionText = match.groupValues[1].trim()
                    val remaining = actionRegex.replaceFirst(msg.content, "").trim()
                    val result = mutableListOf<Message>()
                    if (actionText.isNotEmpty()) {
                        result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
                    }
                    if (remaining.isNotEmpty()) {
                        result.add(msg.copy(content = remaining))
                    }
                    if (result.isEmpty()) listOf(msg) else result
                } else {
                    listOf(msg)
                }
            } else {
                listOf(msg)
            }
        }
    }

    private fun MessageEntity.toDomain() = Message(
        id = id,
        conversationId = conversationId,
        role = role,
        content = content,
        msgType = msgType,
        timestamp = timestamp,
    )
}
