package dev.kaizen.app.chat

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import dev.kaizen.app.ui.icon.KaizenIcons
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.theme.KaizenSemanticColors
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.R
import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity
import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.net.SaveMessage
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
import android.Manifest
import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
import dev.kaizen.app.live.CallStatus
import dev.kaizen.app.live.LiveCallOverlay
import dev.kaizen.app.live.LiveCallService

// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
enum class AppScreen { Chat, Settings }

@Composable
fun ChatScreen(
    settingsViewModel: SettingsViewModel,
    session: SessionViewModel,
    chat: ChatViewModel,
    modifier: Modifier = Modifier
) {
    val haptics = rememberHaptics()
    val scope = rememberCoroutineScope()
    val messages = remember { mutableStateListOf<Message>() }
    val listState = rememberLazyListState()
    var input by remember { mutableStateOf("") }
    var isStreaming by remember { mutableStateOf(false) }
    var nextId by remember { mutableStateOf(0L) }
    var activeMode by remember { mutableStateOf<Int?>(null) }
    var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
    var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
    var webSearchProvider by remember { mutableStateOf("google") }
    var usedTokens by remember { mutableStateOf(0) }
    var chatModel by remember { mutableStateOf<String?>(null) }
    var streamJob by remember { mutableStateOf<Job?>(null) }

    // Tree state for branch navigation
    var chatTree by remember { mutableStateOf(ChatTree.EMPTY) }
    val storedMessagesMap = remember { mutableMapOf<String, dev.kaizen.app.net.StoredMessage>() }
    var editParentId by remember { mutableStateOf<String?>(null) }

    fun rebuildVisibleMessages() {
        val path = getVisiblePath(chatTree)
        messages.clear()
        for (entry in path) {
            val sm = storedMessagesMap[entry.messageId] ?: continue
            messages.add(
                Message(
                    id = nextId++,
                    role = if (sm.role == "assistant") Role.Assistant else Role.User,
                    content = sm.content,
                    wireId = sm.id,
                    attachments = sm.attachments,
                    reasoning = sm.reasoning ?: "",
                    sources = sm.sources ?: emptyList(),
                    branchInfo = entry.branchInfo,
                ),
            )
        }
    }

    // Audio recording state
    var isRecording by remember { mutableStateOf(false) }
    val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
    val audioFileRef = remember { mutableStateOf<File?>(null) }
    var recordingAmplitude by remember { mutableStateOf(0f) }

    // ─── Live Call state (uses context/ttsVoice/currentConversationId declared below) ───
    var liveService by remember { mutableStateOf<LiveCallService?>(null) }
    var liveCallActive by remember { mutableStateOf(false) }
    val liveStatus by liveService?.status?.collectAsState() ?: remember { mutableStateOf(CallStatus.IDLE) }
    val liveSubtitle by liveService?.subtitle?.collectAsState() ?: remember { mutableStateOf("") }
    val liveAmplitude by liveService?.amplitude?.collectAsState() ?: remember { mutableStateOf(0f) }
    val liveUserSpeaking by liveService?.userSpeaking?.collectAsState() ?: remember { mutableStateOf(false) }
    var liveStartedAt by remember { mutableStateOf(0L) }
    var liveElapsedMs by remember { mutableStateOf(0L) }

    LaunchedEffect(isRecording) {
        if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect }
        while (isRecording) {
            val amp = try { recorderRef.value?.maxAmplitude ?: 0 } catch (_: Exception) { 0 }
            recordingAmplitude = (amp / 32768f).coerceIn(0f, 1f)
            kotlinx.coroutines.delay(60)
        }
    }

    // TTS playback state
    var playingTtsForId by remember { mutableStateOf<Long?>(null) }
    val ttsPlayerRef = remember { mutableStateOf<MediaPlayer?>(null) }
    var ttsVoice by remember { mutableStateOf("Kore") }

    // Navigation State
    var currentScreen by remember { mutableStateOf(AppScreen.Chat) }

    // Collect the user name reactively from the ViewModel
    val userName by settingsViewModel.userName.collectAsState()

    // Tablet & Landscape optimization: Detect large screens and cap the content width
    val configuration = LocalConfiguration.current
    val isTablet = configuration.screenWidthDp > 600

    // Native sidebar drawer state
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)

    // Model picker: catalog (loads instantly from local cache!) + sheet visibility
    var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
    var showModelSheet by remember { mutableStateOf(false) }

    // Conversation persistence: sidebar list from Room (instant), active conversation id
    var conversationId by remember { mutableStateOf<String?>(null) }
    var viewingLockedChat by remember { mutableStateOf(false) }
    val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())

    var loadError by remember { mutableStateOf<String?>(null) }

    // Password unlock dialog state — shown when biometrics unavailable for locked chats
    var passwordUnlockTarget by remember { mutableStateOf<ConversationSummary?>(null) }
    var passwordUnlockError by remember { mutableStateOf<String?>(null) }
    var passwordUnlockForToggle by remember { mutableStateOf(false) }

    // Pending file attachments — uploaded immediately on pick, cleared on send.
    val pendingFiles = remember { mutableStateListOf<PendingFile>() }
    val context = LocalContext.current

    // ─── Live Call service binding + control ──────────────────────────────
    val liveServiceConnection = remember {
        object : ServiceConnection {
            override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
                liveService = (binder as? LiveCallService.LocalBinder)?.service
            }
            override fun onServiceDisconnected(name: ComponentName?) {
                liveService = null
            }
        }
    }

    LaunchedEffect(liveCallActive) {
        if (liveCallActive) {
            liveStartedAt = System.currentTimeMillis()
            while (liveCallActive) {
                liveElapsedMs = System.currentTimeMillis() - liveStartedAt
                kotlinx.coroutines.delay(500)
            }
        }
    }

    val liveAvailable = models.any { it.id.startsWith("vertex:") && it.id.contains("live") }

    fun startLiveCallInner() {
        val cfg = session.config ?: return
        liveCallActive = true

        val intent = LiveCallService.intent(context)
        context.startForegroundService(intent)
        context.bindService(intent, liveServiceConnection, 0)

        scope.launch {
            var attempts = 0
            while (liveService == null && attempts < 20) {
                kotlinx.coroutines.delay(50)
                attempts++
            }

            val svc = liveService ?: return@launch

            // Create conversation if needed (same pattern as text chat)
            val cid = conversationId ?: run {
                val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token)
                if (newId != null) {
                    conversationId = newId
                    chat.conversationRepo.insertOptimistic(newId, "Live Call")
                }
                newId
            }

            svc.onTurnEnd = { turn ->
                scope.launch {
                    val convId = conversationId ?: return@launch

                    // Add messages to local UI
                    if (turn.userText.isNotEmpty()) {
                        val userMsg = Message(nextId++, Role.User, turn.userText, wireId = turn.userMessageId ?: java.util.UUID.randomUUID().toString())
                        messages.add(userMsg)
                    }
                    if (turn.assistantText.isNotEmpty()) {
                        val aMsg = Message(nextId++, Role.Assistant, turn.assistantText, wireId = turn.assistantMessageId ?: java.util.UUID.randomUUID().toString())
                        messages.add(aMsg)
                        chatModel = cfg.model
                        usedTokens = turn.usage.inputTokens + turn.usage.outputTokens
                    }

                    // Cache in Room (server already persisted via WS-Server)
                    val msgCount = messages.size
                    val entitiesToCache = mutableListOf<dev.kaizen.app.db.MessageEntity>()
                    if (turn.userMessageId != null && turn.userText.isNotEmpty()) {
                        entitiesToCache.add(dev.kaizen.app.db.MessageEntity(
                            id = turn.userMessageId, conversationId = convId,
                            role = "user", content = turn.userText,
                            attachments = emptyList(), sortOrder = msgCount - 2,
                        ))
                    }
                    if (turn.assistantMessageId != null && turn.assistantText.isNotEmpty()) {
                        entitiesToCache.add(dev.kaizen.app.db.MessageEntity(
                            id = turn.assistantMessageId, conversationId = convId,
                            role = "assistant", content = turn.assistantText,
                            attachments = emptyList(), sortOrder = msgCount - 1,
                        ))
                    }
                    if (entitiesToCache.isNotEmpty()) {
                        chat.messageRepo.cacheMessages(convId, entitiesToCache)
                    }
                }
            }

            svc.onError = { code, message ->
                scope.launch {
                    val errorText = message ?: code
                    Toast.makeText(context, errorText, Toast.LENGTH_SHORT).show()
                }
            }

            svc.startCall(
                baseUrl = cfg.baseUrl,
                token = cfg.token,
                model = cfg.model,
                voice = ttsVoice,
                conversationId = cid,
            )
        }
    }

    val liveCallPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) startLiveCallInner()
        else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show()
    }

    fun startLiveCall() {
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            liveCallPermission.launch(Manifest.permission.RECORD_AUDIO)
            return
        }
        startLiveCallInner()
    }

    fun endLiveCall() {
        liveService?.onTurnEnd = null
        liveService?.onError = null
        liveService?.endCall()
        liveCallActive = false
        try { context.unbindService(liveServiceConnection) } catch (_: Exception) {}
        liveService = null

        // Generate title for new conversations
        val cfg = session.config ?: return
        val cid = conversationId ?: return
        scope.launch {
            val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid)
            if (title != null) chat.conversationRepo.updateTitle(cid, title)
            chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
        }
    }

    var showAttachMenu by remember { mutableStateOf(false) }

    fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) {
        val cfg = session.config ?: return
        val pf = PendingFile(name = name, mimeType = mimeType, bytes = bytes)
        pendingFiles.add(pf)
        scope.launch {
            when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mimeType, bytes)) {
                is FetchResult.Ok -> {
                    val idx = pendingFiles.indexOfFirst { it.id == pf.id }
                    if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
                }
                is FetchResult.Fail -> {
                    val idx = pendingFiles.indexOfFirst { it.id == pf.id }
                    if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false)
                }
            }
        }
    }

    val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
        val cr = context.contentResolver
        for (uri in uris) {
            val mimeType = cr.getType(uri) ?: "image/jpeg"
            val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "image_${System.currentTimeMillis()}"
            val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
            uploadPickedFile(fileName, mimeType, bytes)
        }
    }

    val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
        val cr = context.contentResolver
        for (uri in uris) {
            val mimeType = cr.getType(uri) ?: "application/octet-stream"
            val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
            val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
            uploadPickedFile(fileName, mimeType, bytes)
        }
    }

    val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
        bitmap ?: return@rememberLauncherForActivityResult
        val stream = ByteArrayOutputStream()
        bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 92, stream)
        uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray())
    }

    val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) startRecording(context, recorderRef, audioFileRef) { isRecording = true }
        else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show()
    }

    fun toggleRecording() {
        if (isRecording) {
            stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
                isRecording = false
                val cfg = session.config ?: return@stopRecording
                scope.launch {
                    when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mime, bytes)) {
                        is FetchResult.Ok -> {
                            val att = r.data
                            val userParentId = messages.lastOrNull()?.wireId
                            val userMsg = Message(nextId++, Role.User, "", attachments = listOf(att))
                            messages.add(userMsg)
                            val assistantId = nextId++
                            val assistantWireId = java.util.UUID.randomUUID().toString()
                            messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
                            isStreaming = true
                            haptics.click()

                            val history = messages
                                .filter { it.id != assistantId }
                                .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }

                            val convIdDeferred = if (conversationId == null) {
                                scope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
                            } else null

                            streamJob = launch {
                                haptics.thinkingStart()
                                var sawContent = false
                                try {
                                    KaizenApi.chat(
                                        cfg.baseUrl, cfg.token, cfg.model, history,
                                        attachments = listOf(att),
                                    ).collect { state ->
                                        val idx = messages.indexOfLast { it.id == assistantId }
                                        if (idx < 0) return@collect
                                        if (!sawContent && state.content.isNotEmpty()) {
                                            sawContent = true; haptics.responseStart()
                                        }
                                        messages[idx] = messages[idx].copy(
                                            thinking = if (sawContent) false else messages[idx].thinking,
                                            content = state.content,
                                            reasoning = state.reasoning,
                                            tools = state.tools,
                                            sources = state.sources,
                                            query = state.query,
                                            streaming = true,
                                        )
                                    }
                                } catch (_: kotlinx.coroutines.CancellationException) {
                                } catch (_: Exception) {}
                                val assistantIdx = messages.indexOfLast { it.id == assistantId }
                                if (assistantIdx >= 0) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
                                isStreaming = false
                                haptics.responseEnd()
                                // Persist
                                val cid = convIdDeferred?.await() ?: conversationId ?: return@launch
                                if (conversationId == null) conversationId = cid
                                val assistantMsg = messages.lastOrNull { it.id == assistantId } ?: return@launch
                                val finalContent = assistantMsg.content
                                val finalReasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() }
                                val finalSources = assistantMsg.sources.takeIf { it.isNotEmpty() }
                                val userSave = SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = "", attachments = listOf(att))
                                val assistantSave = SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model, reasoning = finalReasoning, sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) })
                                val saved = KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf(userSave, assistantSave))
                                if (saved) {
                                    val msgCount = messages.size
                                    chat.messageRepo.cacheMessages(cid, listOf(
                                        dev.kaizen.app.db.MessageEntity(
                                            id = userMsg.wireId, conversationId = cid,
                                            role = "user", content = "",
                                            attachments = listOf(att), sortOrder = msgCount - 2,
                                        ),
                                        dev.kaizen.app.db.MessageEntity(
                                            id = assistantWireId, conversationId = cid,
                                            role = "assistant", content = finalContent,
                                            attachments = emptyList(), sortOrder = msgCount - 1,
                                            reasoning = finalReasoning,
                                            sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) } ?: emptyList(),
                                        ),
                                    ))
                                    storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage(
                                        id = userMsg.wireId, role = "user", content = "",
                                        parentId = userParentId, attachments = listOf(att),
                                    )
                                    storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage(
                                        id = assistantWireId, role = "assistant", content = finalContent,
                                        parentId = userMsg.wireId, model = cfg.model,
                                        reasoning = finalReasoning,
                                        sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) },
                                    )
                                    chatTree = buildChatTree(storedMessagesMap.values.toList(), chatTree.activeRootId, chatTree.activeChild)
                                }
                                if (convIdDeferred != null) {
                                    chat.conversationRepo.insertOptimistic(cid, "(Voice)")
                                    val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid)
                                    if (title != null) chat.conversationRepo.updateTitle(cid, title)
                                    chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
                                }
                            }
                        }
                        is FetchResult.Fail -> {
                            Toast.makeText(context, r.reason, Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        } else {
            if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
                startRecording(context, recorderRef, audioFileRef) { isRecording = true }
            } else {
                micPermission.launch(Manifest.permission.RECORD_AUDIO)
            }
        }
    }

    fun playTtsUrl(url: String, messageId: Long) {
        scope.launch {
            try {
                val tmpFile = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
                    val req = okhttp3.Request.Builder().url(url).build()
                    KaizenApi.imageClient.newCall(req).execute().use { resp ->
                        if (!resp.isSuccessful) return@withContext null
                        val bytes = resp.body!!.bytes()
                        java.io.File.createTempFile("tts_", ".wav", context.cacheDir).also { it.writeBytes(bytes) }
                    }
                }
                if (tmpFile == null) {
                    playingTtsForId = null
                    return@launch
                }
                val player = MediaPlayer().apply {
                    setOnPreparedListener { start() }
                    setOnCompletionListener {
                        release()
                        ttsPlayerRef.value = null
                        playingTtsForId = null
                        tmpFile.delete()
                    }
                    setOnErrorListener { _, _, _ ->
                        release()
                        ttsPlayerRef.value = null
                        playingTtsForId = null
                        tmpFile.delete()
                        true
                    }
                    setDataSource(tmpFile.absolutePath)
                    prepareAsync()
                }
                ttsPlayerRef.value = player
            } catch (_: Exception) {
                playingTtsForId = null
            }
        }
    }

    fun readAloud(content: String, messageId: Long, wireId: String) {
        val cfg = session.config ?: return
        if (playingTtsForId == messageId) {
            ttsPlayerRef.value?.release()
            ttsPlayerRef.value = null
            playingTtsForId = null
            return
        }
        ttsPlayerRef.value?.release()
        playingTtsForId = messageId
        val voice = ttsVoice
        scope.launch {
            val cached = chat.messageDao.getTtsUrl(wireId, voice)
            if (cached != null) {
                playTtsUrl(cached, messageId)
                return@launch
            }
            val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content, voice)
            if (url == null) {
                playingTtsForId = null
                Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
                return@launch
            }
            chat.messageDao.updateTts(wireId, url, voice)
            playTtsUrl(url, messageId)
        }
    }

    LaunchedEffect(session.config?.baseUrl, session.config?.token) {
        val cfg = session.config ?: return@LaunchedEffect
        loadError = null
        val errors = mutableListOf<String>()

        when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) {
            is FetchResult.Ok -> {
                models = r.data
                if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data)
            }
            is FetchResult.Fail -> {
                if ("HTTP 401" in r.reason) { session.logout(); return@LaunchedEffect }
                errors.add(r.reason)
            }
        }
        KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me ->
            me.name?.let { settingsViewModel.updateUserName(it) }
            me.email?.let { settingsViewModel.updateUserEmail(it) }
            me.ttsVoice?.let { ttsVoice = it }
        }
        chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
        if (errors.isNotEmpty()) {
            loadError = errors.joinToString(" · ")
        }
        scope.launch { session.checkServerVersion(cfg.baseUrl) }
        scope.launch {
            val ids = chat.conversationRepo.allUnlockedIds()
            chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids)
        }
    }

    // Warm the HTTP/2 connection pool on every app resume (covers background → foreground
    // after OkHttp's 5-minute keep-alive has expired). On first composition, the
    // LaunchedEffect above already warms the pool via fetchModels/fetchConversations.
    LifecycleResumeEffect(session.config?.baseUrl) {
        session.config?.let { cfg ->
            scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
        }
        onPauseOrDispose {
            if (viewingLockedChat) {
                messages.clear()
                viewingLockedChat = false
            }
        }
    }

    fun refreshConversations() {
        val cfg = session.config ?: return
        scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) }
    }

    /** Start a fresh chat — drops the active conversation, the next send creates a new one. */
    fun newChat() {
        messages.clear()
        conversationId = null
        viewingLockedChat = false
        usedTokens = 0
        chatModel = null
        chatTree = ChatTree.EMPTY
        storedMessagesMap.clear()
    }

    fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
        val cfg = session.config ?: return
        scope.launch {
            loadError = null
            val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
            if (unlockToken == null) {
                if (password != null) {
                    passwordUnlockError = context.getString(R.string.unlock_password_wrong)
                } else {
                    loadError = context.getString(R.string.biometric_failed)
                    scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null }
                }
                return@launch
            }
            passwordUnlockTarget = null
            passwordUnlockError = null
            passwordUnlockForToggle = false
            val data = KaizenApi.fetchLockedConversationData(cfg.baseUrl, cfg.token, summary.id, unlockToken)
            if (data.messages.isNotEmpty()) {
                storedMessagesMap.clear()
                data.messages.forEach { storedMessagesMap[it.id] = it }
                chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild)
                rebuildVisibleMessages()
                conversationId = summary.id
                viewingLockedChat = true
            }
        }
    }

    fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) {
        passwordUnlockTarget = summary
        passwordUnlockError = null
        passwordUnlockForToggle = forToggle
    }

    fun requestBiometricOrPassword(
        summary: ConversationSummary,
        forToggle: Boolean = false,
        onBiometricSuccess: () -> Unit,
    ) {
        val biometricEnabled = settingsViewModel.biometricEnabled.value
        var activity: androidx.fragment.app.FragmentActivity? = null
        var ctx = context
        while (ctx is android.content.ContextWrapper) {
            if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
            ctx = ctx.baseContext
        }
        if (!biometricEnabled || activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
            showPasswordFallback(summary, forToggle)
            return
        }
        dev.kaizen.app.auth.BiometricUnlock.authenticate(
            activity = activity,
            title = context.getString(R.string.biometric_title),
            subtitle = context.getString(R.string.biometric_subtitle),
            negativeButtonText = context.getString(R.string.biometric_cancel),
        ) { result ->
            when (result) {
                is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> onBiometricSuccess()
                is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle)
                is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
            }
        }
    }

    fun openConversation(summary: ConversationSummary) {
        val cfg = session.config ?: return
        if (summary.locked) {
            requestBiometricOrPassword(summary) { unlockAndOpen(summary) }
            return
        }
        viewingLockedChat = false
        usedTokens = 0
        loadError = null
        scope.launch {
            messages.clear()
            val cachedList = chat.messageRepo.getCached(summary.id)
            cachedList.forEach { m ->
                messages.add(
                    Message(
                        id = nextId++,
                        role = if (m.role == "assistant") Role.Assistant else Role.User,
                        content = m.content,
                        wireId = m.id,
                        attachments = m.attachments,
                        reasoning = m.reasoning ?: "",
                        sources = m.sources ?: emptyList(),
                    ),
                )
            }
            conversationId = summary.id
            val data = KaizenApi.fetchConversationData(cfg.baseUrl, cfg.token, summary.id)
            if (data.messages.isNotEmpty()) {
                val entities = data.messages.mapIndexed { i, m -> m.toEntity(summary.id, i) }
                chat.messageRepo.cacheMessages(summary.id, entities)
                val lastAssistant = data.messages.lastOrNull { it.role == "assistant" }
                lastAssistant?.model?.let { chatModel = it }
                usedTokens = data.messages.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) }

                storedMessagesMap.clear()
                data.messages.forEach { storedMessagesMap[it.id] = it }
                chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild)
                rebuildVisibleMessages()
            }
        }
    }

    fun send(text: String, branchParentId: String? = null, branchAttachments: List<Attachment> = emptyList()) {
        val trimmed = text.trim()
        val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded }
        if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
        if (isStreaming) return
        val cfg = session.config ?: return
        haptics.click()

        val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId
        editParentId = null
        val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
        messages.add(userMsg)
        input = ""
        if (branchAttachments.isEmpty()) pendingFiles.clear()
        val assistantId = nextId++
        val assistantWireId = java.util.UUID.randomUUID().toString()
        messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
        isStreaming = true
        val wasNewConversation = conversationId == null

        val history = messages
            .filter { it.id != assistantId }
            .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }

        streamJob = scope.launch {
            haptics.thinkingStart()
            var sawContent = false
            try {
                val convIdDeferred = if (conversationId == null) {
                    async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
                } else null

                KaizenApi.chat(
                    cfg.baseUrl, cfg.token, cfg.model, history,
                    reasoningEffort = reasoningPreset.effort,
                    preferThroughput = reasoningPreset.throughput,
                    attachments = uploadedAtts.takeIf { it.isNotEmpty() },
                    webSearch = activeMode == R.string.mode_search,
                    webSearchProvider = if (activeMode == R.string.mode_search && cfg.model.startsWith("vertex:")) webSearchProvider else null,
                    temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value,
                    topP = samplingPrefs.topP.takeIf { it.enabled }?.value,
                    topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(),
                ).collect { state ->
                    val idx = messages.indexOfLast { it.id == assistantId }
                    if (idx < 0) return@collect
                    if (!sawContent && state.content.isNotEmpty()) {
                        sawContent = true
                        haptics.responseStart()
                    }
                    messages[idx] = messages[idx].copy(
                        thinking = if (sawContent) false else messages[idx].thinking,
                        content = state.content,
                        reasoning = state.reasoning,
                        tools = state.tools,
                        query = state.query,
                        sources = state.sources,
                    )
                    if (state.inputTokens > 0 || state.outputTokens > 0) {
                        usedTokens = state.inputTokens + state.outputTokens
                    }
                }
                val idx = messages.indexOfLast { it.id == assistantId }
                val finalMsg = if (idx >= 0) messages[idx] else null
                val finalContent = finalMsg?.content ?: ""
                val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() }
                val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
                if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)

                val convId = convIdDeferred?.await()?.also { cid ->
                    conversationId = cid
                    scope.launch {
                        val preview = trimmed.take(60).lines().first()
                        chat.conversationRepo.insertOptimistic(cid, preview)
                    }
                } ?: conversationId

                if (convId != null && finalContent.isNotEmpty()) {
                    val saved = KaizenApi.saveMessages(
                        cfg.baseUrl, cfg.token, convId,
                        listOf(
                            SaveMessage(
                                id = userMsg.wireId, parentId = userParentId,
                                role = "user", content = trimmed,
                                attachments = uploadedAtts.takeIf { it.isNotEmpty() },
                            ),
                            SaveMessage(
                                id = assistantWireId, parentId = userMsg.wireId,
                                role = "assistant", content = finalContent, model = cfg.model,
                                reasoning = finalReasoning,
                                sources = finalSources,
                            ),
                        ),
                    )
                    if (saved) {
                        val msgCount = messages.size
                        chat.messageRepo.cacheMessages(convId, listOf(
                            MessageEntity(
                                id = userMsg.wireId, conversationId = convId,
                                role = "user", content = trimmed,
                                attachments = uploadedAtts, sortOrder = msgCount - 2,
                            ),
                            MessageEntity(
                                id = assistantWireId, conversationId = convId,
                                role = "assistant", content = finalContent,
                                attachments = emptyList(), sortOrder = msgCount - 1,
                                reasoning = finalReasoning,
                                sources = finalSources ?: emptyList(),
                            ),
                        ))

                        // Update tree with the new nodes
                        storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage(
                            id = userMsg.wireId, role = "user", content = trimmed,
                            parentId = userParentId, attachments = uploadedAtts,
                        )
                        storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage(
                            id = assistantWireId, role = "assistant", content = finalContent,
                            parentId = userMsg.wireId, model = cfg.model,
                            reasoning = finalReasoning,
                            sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) },
                        )
                        chatTree = buildChatTree(
                            storedMessagesMap.values.toList(),
                            chatTree.activeRootId,
                            chatTree.activeChild,
                        )

                        if (wasNewConversation) {
                            val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
                            if (title != null) {
                                chat.conversationRepo.updateTitle(convId, title)
                            }
                        }
                        refreshConversations()
                    }
                }
            } catch (e: Exception) {
                val idx = messages.indexOfLast { it.id == assistantId }
                if (e is kotlinx.coroutines.CancellationException) {
                    if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
                } else if (e is ChatHttpException && e.code == 401) {
                    if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
                    session.logout()
                } else if (idx >= 0) {
                    val existing = messages[idx].content
                    val errorSuffix = "\n\n⚠ ${chatErrorText(e, context)}"
                    messages[idx] = messages[idx].copy(
                        streaming = false,
                        thinking = false,
                        content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context),
                    )
                }
            } finally {
                isStreaming = false
                streamJob = null
                haptics.responseEnd()
            }
        }
    }

    // Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow.
    // Only the snapshot read triggers work — the root composable does NOT recompose on
    // every streamed character, eliminating the recomposition storm that previously
    // re-drew the entire screen (top bar, input, background) at 60+ fps.
    val autoScrollKey by remember {
        derivedStateOf { messages.size to (messages.lastOrNull()?.content?.length ?: 0) }
    }
    LaunchedEffect(Unit) {
        snapshotFlow { autoScrollKey }.collect {
            if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
        }
    }

    // Single source of truth Screen routing
    when (currentScreen) {
        AppScreen.Settings -> {
            SettingsScreen(
                viewModel = settingsViewModel,
                config = session.config,
                onBack = { currentScreen = AppScreen.Chat },
                modifier = modifier
            )
        }
        AppScreen.Chat -> {
            // ModalNavigationDrawer provides a native drawer that slides from left-to-right
            ModalNavigationDrawer(
                drawerState = drawerState,
                drawerContent = {
                    KaizenSidebar(
                        userName = userName,
                        conversations = conversations,
                        currentId = conversationId,
                        onClose = { scope.launch { drawerState.close() } },
                        onNewChat = {
                            haptics.tick()
                            newChat()
                            scope.launch { drawerState.close() }
                        },
                        onSelectConversation = { summary ->
                            haptics.tick()
                            openConversation(summary)
                            scope.launch { drawerState.close() }
                        },
                        onOpenSettings = {
                            haptics.tick()
                            currentScreen = AppScreen.Settings
                            scope.launch { drawerState.close() }
                        },
                        onLogout = {
                            haptics.tick()
                            newChat()
                            scope.launch { drawerState.close() }
                            session.logout()
                        },
                        onAction = { action ->
                            val cfg = session.config ?: return@KaizenSidebar
                            haptics.tick()
                            scope.launch {
                                when (action) {
                                    is SidebarAction.Rename -> {
                                        KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle))
                                        refreshConversations()
                                    }
                                    is SidebarAction.Delete -> {
                                        KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id)
                                        if (conversationId == action.id) newChat()
                                        refreshConversations()
                                    }
                                    is SidebarAction.TogglePin -> {
                                        KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned))
                                        refreshConversations()
                                    }
                                    is SidebarAction.ToggleLock -> {
                                        if (action.locked) {
                                            KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
                                            refreshConversations()
                                        } else {
                                            val summary = conversations.find { it.id == action.id }
                                            if (summary != null) {
                                                requestBiometricOrPassword(summary, forToggle = true) {
                                                    scope.launch {
                                                        KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false))
                                                        refreshConversations()
                                                    }
                                                }
                                            }
                                        }
                                    }
                                    is SidebarAction.GenerateTitle -> {
                                        val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, action.id, manual = true)
                                        if (title != null) {
                                            chat.conversationRepo.updateTitle(action.id, title)
                                            refreshConversations()
                                        }
                                    }
                                    is SidebarAction.Duplicate -> {
                                        val srcMessages = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, action.id)
                                        if (srcMessages.isNotEmpty()) {
                                            val srcTitle = conversations.find { it.id == action.id }?.title ?: ""
                                            val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token, "$srcTitle (2)")
                                            if (newId != null) {
                                                val saveMessages = srcMessages.mapIndexed { i, m ->
                                                    dev.kaizen.app.net.SaveMessage(
                                                        id = java.util.UUID.randomUUID().toString(),
                                                        parentId = if (i == 0) null else null,
                                                        role = m.role,
                                                        content = m.content,
                                                        model = m.model,
                                                        attachments = m.attachments.takeIf { it.isNotEmpty() },
                                                        reasoning = m.reasoning,
                                                        sources = m.sources,
                                                    )
                                                }
                                                // Build parentId chain
                                                val chained = saveMessages.mapIndexed { i, m ->
                                                    m.copy(parentId = if (i == 0) null else saveMessages[i - 1].id)
                                                }
                                                KaizenApi.saveMessages(cfg.baseUrl, cfg.token, newId, chained)
                                                refreshConversations()
                                            }
                                        }
                                    }
                                }
                            }
                        },
                    )
                },
                gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
            ) {
                MeshBackground(animateBlobs = messages.isEmpty()) {
                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                        // 1. Scrollable Chat Content
                        // Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating bottom dock.
                        // Centered layout with responsive width capping on tablets.
                        if (messages.isEmpty()) {
                            EmptyHero(
                                userName = userName,
                                onPick = { haptics.tick(); send(it) },
                                modifier = Modifier
                                    .fillMaxSize()
                                    .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
                            )
                        } else {
                            LazyColumn(
                                state = listState,
                                modifier = Modifier
                                    .fillMaxSize()
                                    .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
                                contentPadding = PaddingValues(
                                    top = 100.dp,
                                    bottom = 140.dp,
                                    start = 16.dp,
                                    end = 16.dp
                                ),
                                verticalArrangement = Arrangement.spacedBy(24.dp),
                            ) {
                                items(messages, key = { it.id }) { message ->
                                    Box(Modifier.animateItem(
                                        fadeInSpec = tween(350),
                                        placementSpec = spring(stiffness = Spring.StiffnessMediumLow),
                                        fadeOutSpec = tween(200),
                                    )) {
                                    MessageRow(
                                        message = message,
                                        onDelete = if (conversationId != null && !isStreaming) { wireId ->
                                            val cfg = session.config ?: return@MessageRow
                                            messages.removeAll { it.wireId == wireId }
                                            scope.launch {
                                                KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, wireId)
                                            }
                                        } else null,
                                        onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ ->
                                            val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) }
                                            if (lastUserIdx >= 0) {
                                                val userMsg = messages[lastUserIdx]
                                                val parentId = chatTree.nodes[userMsg.wireId]?.parentId
                                                val msgIdx = messages.indexOf(message)
                                                if (msgIdx >= 0) {
                                                    messages.subList(msgIdx, messages.size).clear()
                                                }
                                                send(userMsg.content, branchParentId = parentId, branchAttachments = userMsg.attachments)
                                            }
                                        } else null,
                                        onEdit = if (message.role == Role.User && !isStreaming) { wireId, content ->
                                            val node = chatTree.nodes[wireId]
                                            val parentId = node?.parentId
                                            val idx = messages.indexOfFirst { it.wireId == wireId }
                                            if (idx >= 0) {
                                                messages.subList(idx, messages.size).clear()
                                                editParentId = parentId
                                                input = content
                                            }
                                        } else null,
                                        onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content ->
                                            readAloud(content, message.id, message.wireId)
                                        } else null,
                                        isPlayingTts = playingTtsForId == message.id,
                                        branchInfo = message.branchInfo,
                                        onNavigateBranch = if (!isStreaming) { wireId, direction ->
                                            val newTree = treeNavigate(chatTree, wireId, direction)
                                            chatTree = newTree
                                            rebuildVisibleMessages()
                                            if (conversationId != null) {
                                                val cfg = session.config ?: return@MessageRow
                                                scope.launch {
                                                    KaizenApi.patchConversation(
                                                        cfg.baseUrl, cfg.token, conversationId!!,
                                                        mapOf(
                                                            "activeRootId" to newTree.activeRootId,
                                                            "activeChild" to newTree.activeChild,
                                                        ),
                                                    )
                                                }
                                            }
                                        } else null,
                                    )
                                    }
                                }
                            }

                            if (!isStreaming) {
                                val canScrollUp by remember { derivedStateOf { listState.canScrollBackward } }
                                val canScrollDown by remember { derivedStateOf { listState.canScrollForward } }
                                val showButton = canScrollUp || canScrollDown
                                val nearBottom by remember { derivedStateOf {
                                    val info = listState.layoutInfo
                                    val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
                                    val total = info.totalItemsCount
                                    if (total <= 0) true else lastVisible >= total - 2
                                }}
                                val pointsUp = !nearBottom && canScrollUp

                                if (showButton && !(canScrollUp && !canScrollDown && nearBottom)) {
                                    GlassSurface(
                                        shape = KaizenShapes.circle,
                                        tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
                                        shadowStack = KaizenShadows.level1,
                                        cornerRadius = 18.dp,
                                        modifier = Modifier
                                            .align(Alignment.CenterEnd)
                                            .padding(end = 8.dp)
                                            .size(36.dp)
                                            .clickable {
                                                scope.launch {
                                                    if (pointsUp) listState.animateScrollToItem(0)
                                                    else listState.animateScrollToItem(messages.size - 1)
                                                }
                                            },
                                    ) {
                                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                                            Icon(
                                                if (pointsUp) KaizenIcons.ArrowUp else KaizenIcons.ArrowDown,
                                                null,
                                                tint = MaterialTheme.colorScheme.onBackground,
                                                modifier = Modifier.size(20.dp),
                                            )
                                        }
                                    }
                                }
                            }
                        }

                        // 2. Floating Top Menu Button
                        val scrimColor = MaterialTheme.colorScheme.background
                        Box(
                            modifier = Modifier
                                .align(Alignment.TopStart)
                                .fillMaxWidth()
                                .background(
                                    Brush.verticalGradient(
                                        colorStops = arrayOf(
                                            0f to scrimColor,
                                            0.7f to scrimColor.copy(alpha = 0.55f),
                                            1f to Color.Transparent,
                                        ),
                                    )
                                )
                                .statusBarsPadding()
                        ) {
                            Row(
                                modifier = Modifier.padding(start = 14.dp, top = 10.dp, end = 14.dp, bottom = 20.dp),
                                verticalAlignment = Alignment.CenterVertically,
                            ) {
                                val displayModel = chatModel ?: session.config?.model ?: ""
                                // Unified top bar: sidebar trigger + model name in one glass pill
                                GlassSurface(
                                    shape = KaizenShapes.pill,
                                    tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
                                    shadowStack = KaizenShadows.level1,
                                    cornerRadius = 20.dp,
                                ) {
                                    Row(
                                        Modifier.heightIn(min = 38.dp),
                                        verticalAlignment = Alignment.CenterVertically,
                                    ) {
                                        // Sidebar trigger area
                                        Box(
                                            Modifier
                                                .size(38.dp)
                                                .clip(KaizenShapes.circle)
                                                .clickable { haptics.tick(); scope.launch { drawerState.open() } },
                                            contentAlignment = Alignment.Center,
                                        ) {
                                            Icon(
                                                imageVector = KaizenIcons.Menu,
                                                contentDescription = context.getString(R.string.chat_open_menu),
                                                tint = MaterialTheme.colorScheme.onSurfaceVariant,
                                                modifier = Modifier.size(17.dp),
                                            )
                                        }
                                        // Divider dot
                                        Box(
                                            Modifier
                                                .size(3.dp)
                                                .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f), KaizenShapes.circle)
                                        )
                                        // Model name — tap opens picker
                                        Box(
                                            Modifier
                                                .clip(KaizenShapes.pill)
                                                .clickable { haptics.tick(); showModelSheet = true }
                                                .padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
                                        ) {
                                            Text(
                                                modelDisplayName(displayModel, models),
                                                color = MaterialTheme.colorScheme.onBackground,
                                                fontSize = 13.sp,
                                                fontWeight = FontWeight.W300,
                                                maxLines = 1,
                                                modifier = Modifier.widthIn(max = 180.dp),
                                                overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
                                            )
                                        }
                                    }
                                }
                                Spacer(Modifier.weight(1f))
                                Box(
                                    Modifier.size(36.dp).clip(KaizenShapes.circle)
                                        .clickable { haptics.tick(); newChat() },
                                    contentAlignment = Alignment.Center,
                                ) {
                                    Icon(
                                        KaizenIcons.PenLine, null,
                                        tint = MaterialTheme.colorScheme.onSurfaceVariant,
                                        modifier = Modifier.size(17.dp),
                                    )
                                }
                                Spacer(Modifier.width(4.dp))
                                val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
                                TokenBadge(used = usedTokens, contextLength = ctxLen)
                            }
                        }

                        // Error banner when initial data load fails
                        if (loadError != null) {
                            Box(
                                modifier = Modifier
                                    .align(Alignment.TopCenter)
                                    .statusBarsPadding()
                                    .padding(top = 62.dp, start = 24.dp, end = 24.dp)
                                    .clip(KaizenShapes.sm)
                                    .background(KaizenSemanticColors.errorDeep.copy(alpha = 0.15f))
                                    .border(1.dp, KaizenSemanticColors.errorDeep.copy(alpha = 0.3f), KaizenShapes.sm)
                                    .padding(horizontal = 14.dp, vertical = 8.dp),
                            ) {
                                Text(
                                    loadError!!,
                                    color = KaizenSemanticColors.errorDeep,
                                    fontSize = 12.sp,
                                    fontWeight = FontWeight.Medium,
                                )
                            }
                        }

                        // Version skew warning banner
                        val skew = session.versionSkew
                        if (skew != null) {
                            val warningColor = Color(0xFFD97706)
                            Box(
                                modifier = Modifier
                                    .align(Alignment.TopCenter)
                                    .statusBarsPadding()
                                    .padding(top = if (loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp)
                                    .clip(KaizenShapes.sm)
                                    .background(warningColor.copy(alpha = 0.15f))
                                    .border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm)
                                    .clickable { session.dismissVersionWarning() }
                                    .padding(horizontal = 14.dp, vertical = 8.dp),
                            ) {
                                Text(
                                    stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion),
                                    color = warningColor,
                                    fontSize = 12.sp,
                                    fontWeight = FontWeight.Medium,
                                )
                            }
                        }

                        // 3. Floating Bottom Control Dock
                        @OptIn(ExperimentalLayoutApi::class)
                        val imeVisible = WindowInsets.isImeVisible
                        val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
                        val navBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current)
                        val bottomOffset = if (imeVisible) maxOf(imeBottom, navBottom) else navBottom
                        Column(
                            modifier = Modifier
                                .align(Alignment.BottomCenter)
                                .offset(y = with(LocalDensity.current) { -bottomOffset.toDp() })
                                .background(
                                    if (imeVisible) {
                                        Brush.verticalGradient(
                                            colorStops = arrayOf(
                                                0f to scrimColor.copy(alpha = 0.5f),
                                                1f to scrimColor.copy(alpha = 0.85f),
                                            ),
                                        )
                                    } else {
                                        Brush.verticalGradient(
                                            colorStops = arrayOf(
                                                0f to Color.Transparent,
                                                0.5f to scrimColor.copy(alpha = 0.25f),
                                                1f to scrimColor.copy(alpha = 0.55f),
                                            ),
                                        )
                                    }
                                )
                                .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
                        ) {
                            Spacer(Modifier.height(if (imeVisible) 4.dp else 12.dp))
                            ChatInput(
                                value = input,
                                onValueChange = { input = it },
                                onSend = { send(input) },
                                enabled = !isStreaming,
                                isStreaming = isStreaming,
                                onStop = { streamJob?.cancel() },
                                isRecording = isRecording,
                                recordingAmplitude = recordingAmplitude,
                                onMicClick = { toggleRecording() },
                                onCallClick = if (liveAvailable) {{ startLiveCall() }} else {{ /* PTT TODO */ }},
                                pendingFiles = pendingFiles,
                                onAddClick = { showAttachMenu = !showAttachMenu },
                                onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
                                pillsContent = {
                                    ModePillsRow(
                                        activeMode = activeMode,
                                        onToggle = { mode ->
                                            activeMode = if (activeMode == mode) null else mode
                                            haptics.tick()
                                        },
                                        reasoningPreset = reasoningPreset,
                                        onReasoningChange = { reasoningPreset = it; haptics.tick() },
                                        samplingPrefs = samplingPrefs,
                                        onSamplingChange = { samplingPrefs = it },
                                        currentModelId = session.config?.model ?: "",
                                        webSearchProvider = webSearchProvider,
                                        onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
                                    )
                                },
                            )
                            Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
                        }

                        // Dismiss scrim — tap anywhere to close the attachment menu
                        if (showAttachMenu) {
                            Box(
                                Modifier
                                    .fillMaxSize()
                                    .clickable(
                                        interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
                                        indication = null,
                                    ) { showAttachMenu = false }
                            )
                        }

                        // Attachment menu — floats above the + button at bottom-left
                        androidx.compose.animation.AnimatedVisibility(
                            visible = showAttachMenu,
                            modifier = Modifier
                                .align(Alignment.BottomStart)
                                .padding(start = 6.dp, bottom = 140.dp),
                            enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.slideInVertically { it / 2 },
                            exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.slideOutVertically { it / 2 },
                        ) {
                            AttachmentMenu(
                                onCamera = { showAttachMenu = false; cameraPicker.launch(null) },
                                onGallery = { showAttachMenu = false; galleryPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) },
                                onFile = { showAttachMenu = false; filePicker.launch(arrayOf("*/*")) },
                            )
                        }

                        // Model picker (bottom sheet), opened by the top pill
                        if (showModelSheet) {
                            ModelSheet(
                                models = models,
                                selectedModelId = session.config?.model ?: "",
                                favorites = session.favorites,
                                onSelectModel = { session.setModel(it); chatModel = it; haptics.tick() },
                                onToggleFavorite = { session.toggleFavorite(it) },
                                onDismiss = { showModelSheet = false },
                            )
                        }

                        // Password fallback dialog for locked conversations
                        val pwTarget = passwordUnlockTarget
                        if (pwTarget != null) {
                            dev.kaizen.app.auth.PasswordUnlockDialog(
                                onSubmit = { password ->
                                    val cfg = session.config ?: return@PasswordUnlockDialog
                                    if (passwordUnlockForToggle) {
                                        scope.launch {
                                            val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
                                            if (token == null) {
                                                passwordUnlockError = context.getString(R.string.unlock_password_wrong)
                                            } else {
                                                KaizenApi.patchConversation(cfg.baseUrl, cfg.token, pwTarget.id, mapOf("locked" to false))
                                                refreshConversations()
                                                passwordUnlockTarget = null
                                                passwordUnlockError = null
                                                passwordUnlockForToggle = false
                                            }
                                        }
                                    } else {
                                        unlockAndOpen(pwTarget, password)
                                    }
                                },
                                onDismiss = {
                                    passwordUnlockTarget = null
                                    passwordUnlockError = null
                                    passwordUnlockForToggle = false
                                },
                                error = passwordUnlockError,
                            )
                        }
                    }
                }
            }

            // ─── Live Call Overlay ────────────────────────────────
            androidx.compose.animation.AnimatedVisibility(
                visible = liveCallActive,
                enter = androidx.compose.animation.fadeIn(tween(250)),
                exit = androidx.compose.animation.fadeOut(tween(200)),
            ) {
                LiveCallOverlay(
                    status = liveStatus,
                    subtitle = liveSubtitle,
                    amplitude = liveAmplitude,
                    userSpeaking = liveUserSpeaking,
                    elapsedMs = liveElapsedMs,
                    onEndCall = { endLiveCall() },
                    onInterrupt = { liveService?.interrupt() },
                )
            }
        }
    }
}

private fun chatErrorText(e: Throwable, ctx: android.content.Context): String = when {
    e is ChatHttpException && e.code == 401 -> ctx.getString(R.string.error_session_expired)
    e is ChatHttpException && e.code == 402 -> ctx.getString(R.string.error_credits_exhausted)
    e is ChatHttpException && e.code == 429 -> ctx.getString(R.string.error_rate_limited)
    e is ChatHttpException -> ctx.getString(R.string.error_server, e.code)
    else -> ctx.getString(R.string.error_no_connection)
}

@Suppress("DEPRECATION")
private fun startRecording(
    context: android.content.Context,
    recorderRef: androidx.compose.runtime.MutableState<MediaRecorder?>,
    audioFileRef: androidx.compose.runtime.MutableState<File?>,
    onStarted: () -> Unit,
) {
    try {
        val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
        val recorder = MediaRecorder(context).apply {
            setAudioSource(MediaRecorder.AudioSource.MIC)
            setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
            setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
            setAudioSamplingRate(44100)
            setAudioEncodingBitRate(128000)
            setOutputFile(file.absolutePath)
            prepare()
            start()
        }
        recorderRef.value = recorder
        audioFileRef.value = file
        onStarted()
    } catch (_: Exception) {
        recorderRef.value = null
        audioFileRef.value = null
    }
}

private fun stopRecording(
    recorderRef: androidx.compose.runtime.MutableState<MediaRecorder?>,
    audioFileRef: androidx.compose.runtime.MutableState<File?>,
    onRecorded: (String, String, ByteArray) -> Unit,
) {
    val recorder = recorderRef.value ?: return
    val file = audioFileRef.value ?: return
    try {
        recorder.stop()
        recorder.release()
    } catch (_: Exception) {
        recorder.release()
    }
    recorderRef.value = null
    audioFileRef.value = null
    if (file.exists() && file.length() > 0) {
        val bytes = file.readBytes()
        file.delete()
        onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", bytes)
    }
}
