package cn.learningpad.oraltrainer.sample

import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.media.MediaCodec
import android.media.MediaFormat
import android.media.MediaExtractor
import android.media.MediaMuxer
import android.media.MediaRecorder
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.StrictMode
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.TranslateAnimation
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.AuthSession
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
import cn.learningpad.oraltrainer.sdk.LoopMode
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.OralTrainerListener
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
import cn.learningpad.oraltrainer.sdk.PlayerConfig
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
import java.io.File
import java.io.DataOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.util.Locale
import org.json.JSONObject
import androidx.core.content.FileProvider
import kotlin.math.max
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer

class MainActivity : Activity() {
    private lateinit var sdk: OralTrainerSdk
    private lateinit var controller: OralTrainerController
    private lateinit var controllerListener: OralTrainerListener
    private lateinit var playerView: OralTrainerPlayerView
    private lateinit var lessonTitleText: TextView
    private lateinit var statusText: TextView
    private lateinit var sentenceText: TextView
    private lateinit var sentenceMetaText: TextView
    private lateinit var timeText: TextView
    private lateinit var speedText: TextView
    private lateinit var progressBar: SeekBar
    private var progressBarDragging = false
    private lateinit var catalogList: LinearLayout
    private lateinit var catalogStatusText: TextView
    private lateinit var testStatusText: TextView
    private lateinit var scoreSummaryText: TextView
    private lateinit var scoreDetailText: TextView
    private lateinit var recordButton: Button
    private lateinit var playDubButton: Button
    private lateinit var mergeDubButton: Button
    private lateinit var shareDubButton: Button
    private var dubPlayer: ExoPlayer? = null
    private var dubPlayerListener: Player.Listener? = null
    private var dubPlaybackGeneration = 0
    private val mainHandler = Handler(Looper.getMainLooper())
    private var mediaRecorder: MediaRecorder? = null
    private var recordingFile: File? = null
    private val dubSegments = mutableMapOf<Int, File>()
    private val latestScores = mutableMapOf<Int, ImitationAssessmentResult>()
    private var mergedDubFile: File? = null
    private var dubbingStatusText: TextView? = null
    private var dubbingPlayback = false

    private var activeItemId: String = ""
    private var currentSentenceCount = 0
    private var authToken: String = ""
    private var authNickname: String = ""
    private var authUsernameInput: EditText? = null
    private var authPasswordInput: EditText? = null
    private var authStatusText: TextView? = null
    private var regUsernameInput: EditText? = null
    private var regPasswordInput: EditText? = null
    private var regNicknameInput: EditText? = null
    private var regPhoneInput: EditText? = null
    private var regStatusText: TextView? = null
    private var showingRegisterPage = false
    private var myContentList: LinearLayout? = null
    private var sentenceBoundaryRequestGeneration = 0
    private var catalogVideos: List<TrainingVideoSummary> = emptyList()
    private val courseEnrollment = mutableMapOf<String, Boolean>()
    private var catalogStatusTextValue = "正在同步"
    private var activeModule = Module.TRAIN
    private var continuousPlaybackEnabled = false
    private var testSubtitleVisible = false
    private var landscapeSubtitleVisible = true

    private val screenActionReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            if (intent?.action == Intent.ACTION_SCREEN_OFF) {
                if (dubbingPlayback) {
                    stopDubPlayback("已停止配音播放")
                } else {
                    controller.pause()
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        configureWindow()

        val authPreferences = getSharedPreferences("oral_trainer_auth", MODE_PRIVATE)
        authToken = authPreferences.getString("token", "").orEmpty()
        authNickname = authPreferences.getString("nickname", "").orEmpty()

        sdk = OralTrainerSdk.init(
            this,
            OralTrainerSdkConfig(
                sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
                assessmentApiKey = ASSESSMENT_API_KEY,
                userToken = authToken.takeIf { it.isNotBlank() },
                allowHttpFallback = true,
            )
        )
        controller = sdk.createController(
            playerConfig = PlayerConfig(
                sentenceMode = true,
                defaultSeekStepMs = 10_000L,
                autoPlay = false,
                continuousPlayback = false,
            ),
            imitationAssessor = sdk.createRemoteImitationQualityAssessor(),
        )
        controller.setLoopMode(LoopMode.ALL)
        controllerListener = createControllerListener()
        controller.addListener(controllerListener)

        setRootView()
        loadCatalog()
        if (isLoggedIn()) refreshCurrentUi()

        registerReceiver(
            screenActionReceiver,
            IntentFilter(Intent.ACTION_SCREEN_OFF),
        )
    }

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        setRootView()
        renderCatalog()
        refreshCurrentUi()
        if (activeModule == Module.TEST) {
            refreshTestUi()
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray,
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode != RECORD_AUDIO_REQUEST) {
            return
        }
        if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
            startRecording()
        } else if (::testStatusText.isInitialized) {
            testStatusText.text = "需要麦克风权限才能进行录音评测"
        }
    }

    override fun onPause() {
        super.onPause()
        if (dubbingPlayback) {
            stopDubPlayback("已停止配音播放")
        } else {
            controller.pause()
        }
    }

    override fun onResume() {
        super.onResume()
        registerReceiver(
            screenActionReceiver,
            IntentFilter(Intent.ACTION_SCREEN_OFF),
        )
    }

    override fun onStop() {
        runCatching { unregisterReceiver(screenActionReceiver) }
        super.onStop()
    }

    @Deprecated("Deprecated in Java")
    override fun onBackPressed() {
        if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
            requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
        } else {
            moveTaskToBack(true)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode != PICK_VIDEO_REQUEST || resultCode != RESULT_OK) {
            return
        }
        val uri = data?.data ?: return
        val persistableFlags = data.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION
        if (persistableFlags != 0) {
            runCatching {
                contentResolver.takePersistableUriPermission(uri, persistableFlags)
            }
        }

        val item = TrainingMediaItem(
            id = uri.toString(),
            title = uri.lastPathSegment ?: "本地视频",
            uri = uri,
        )
        activeItemId = item.id
        currentSentenceCount = 0
        controller.loadItem(item)
        lessonTitleText.text = item.title
        sentenceMetaText.text = "句子边界分析中"
        sentenceText.text = "正在匹配服务端句子边界"
        statusText.text = "正在获取本地视频的句子边界..."
        renderCatalog()

        sdk.sentenceBoundaryApi.fetchForUri(uri, contentResolver, object : SentenceBoundaryApiCallback {
            override fun onSuccess(result: SentenceBoundaryApiResult) {
                if (activeItemId != item.id) {
                    return
                }
                currentSentenceCount = result.sentences.size
                controller.loadItem(item.copy(sentences = result.sentences))
                statusText.text = "句子边界已加载：${result.sentences.size} 句"
            }

            override fun onError(error: Throwable) {
                if (activeItemId != item.id) {
                    return
                }
                statusText.text = "句子边界获取失败，使用默认快退/快进：${error.message.orEmpty()}"
                sentenceMetaText.text = "暂无句子边界"
                sentenceText.text = item.title
            }
        })
    }

    override fun onDestroy() {
        releaseDubbingResources()
        controller.release()
        super.onDestroy()
    }

    @Suppress("DEPRECATION")
    private fun configureWindow() {
        window.statusBarColor = COLOR_BACKGROUND
        window.navigationBarColor = COLOR_BACKGROUND
    }

    private fun createContentView(): View {
        val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
        if (activeModule == Module.COURSE) {
            return LinearLayout(this).apply {
                orientation = LinearLayout.VERTICAL
                setBackgroundColor(COLOR_BACKGROUND)
                addView(createModuleTabBar())
                addView(createCourseSection())
            }
        }
        if (activeModule == Module.MINE) {
            return LinearLayout(this).apply {
                orientation = LinearLayout.VERTICAL
                setBackgroundColor(COLOR_BACKGROUND)
                addView(createModuleTabBar())
                addView(createMySection())
            }
        }
        return if (isLandscape) {
            createLandscapeContentView()
        } else {
            createPortraitContentView()
        }
    }

    private fun setRootView() {
        setContentView(
            when {
                isLoggedIn() -> createContentView()
                showingRegisterPage -> createRegisterView()
                else -> createAuthView()
            }
        )
        if (isLoggedIn()) {
            renderCatalog()
            refreshCurrentUi()
        }
    }

    private fun isLoggedIn(): Boolean = authToken.isNotBlank()

    private fun createAuthView(): View {
        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            gravity = Gravity.CENTER
            setBackgroundColor(COLOR_BACKGROUND)
            setPadding(28.dp, 28.dp, 28.dp, 28.dp)

            addView(TextView(this@MainActivity).apply {
                text = "八哥口语"
                textSize = 32f
                typeface = Typeface.DEFAULT_BOLD
                setTextColor(Color.WHITE)
            })
            addView(TextView(this@MainActivity).apply {
                text = "请先注册或登录后开始学习"
                textSize = 15f
                setTextColor(COLOR_TEXT_MUTED)
                setPadding(0, 6.dp, 0, 24.dp)
            })

            authUsernameInput = EditText(this@MainActivity).apply {
                hint = "用户名"
                setSingleLine(true)
            }
            authPasswordInput = EditText(this@MainActivity).apply {
                hint = "密码（至少 8 位）"
                inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
                setSingleLine(true)
            }
            authStatusText = TextView(this@MainActivity).apply {
                setTextColor(COLOR_ACCENT_LIGHT)
                textSize = 13f
                setPadding(0, 14.dp, 0, 0)
            }

            addView(authUsernameInput)
            addView(authPasswordInput)
            addView(Button(this@MainActivity).apply {
                text = "登录"
                background = rounded(COLOR_ACCENT, 8f)
                setTextColor(Color.WHITE)
                setOnClickListener { authenticate(register = false) }
                layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 20.dp, 0, 8.dp)
            })
            addView(Button(this@MainActivity).apply {
                text = "注册"
                setOnClickListener {
                    showingRegisterPage = true
                    setRootView()
                }
                layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
            })
            addView(authStatusText)
        }
    }

    private fun createRegisterView(): View {
        return ScrollView(this).apply {
            layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
            setBackgroundColor(COLOR_BACKGROUND)
            addView(LinearLayout(this@MainActivity).apply {
                orientation = LinearLayout.VERTICAL
                gravity = Gravity.CENTER_HORIZONTAL
                setPadding(28.dp, 60.dp, 28.dp, 28.dp)

                addView(TextView(this@MainActivity).apply {
                    text = "注册账号"
                    textSize = 28f
                    typeface = Typeface.DEFAULT_BOLD
                    setTextColor(Color.WHITE)
                })
                addView(TextView(this@MainActivity).apply {
                    text = "填写以下信息完成注册"
                    textSize = 14f
                    setTextColor(COLOR_TEXT_MUTED)
                    setPadding(0, 6.dp, 0, 24.dp)
                })

                addView(fieldLabel("用户名"))
                regUsernameInput = EditText(this@MainActivity).apply {
                    hint = "3-50 位字母、数字或下划线"
                    setSingleLine(true)
                }
                addView(regUsernameInput)

                addView(fieldLabel("密码"))
                regPasswordInput = EditText(this@MainActivity).apply {
                    hint = "至少 8 位"
                    inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
                    setSingleLine(true)
                }
                addView(regPasswordInput)

                addView(fieldLabel("姓名"))
                regNicknameInput = EditText(this@MainActivity).apply {
                    hint = "你的真实姓名或昵称"
                    setSingleLine(true)
                }
                addView(regNicknameInput)

                addView(fieldLabel("手机号"))
                regPhoneInput = EditText(this@MainActivity).apply {
                    hint = "选填"
                    inputType = android.text.InputType.TYPE_CLASS_PHONE
                    setSingleLine(true)
                }
                addView(regPhoneInput)

                regStatusText = TextView(this@MainActivity).apply {
                    setTextColor(COLOR_ACCENT_LIGHT)
                    textSize = 13f
                    setPadding(0, 14.dp, 0, 0)
                }

                addView(Button(this@MainActivity).apply {
                    text = "注册"
                    background = rounded(COLOR_ACCENT, 8f)
                    setTextColor(Color.WHITE)
                    setOnClickListener { submitRegistration() }
                    layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 8.dp)
                })
                addView(Button(this@MainActivity).apply {
                    text = "返回登录"
                    setOnClickListener {
                        showingRegisterPage = false
                        regStatusText = null
                        setRootView()
                    }
                    layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
                })
                addView(regStatusText)
            })
        }
    }

    private fun fieldLabel(label: String): TextView {
        return TextView(this).apply {
            text = label
            textSize = 13f
            setTextColor(COLOR_TEXT_MUTED)
            setPadding(0, 12.dp, 0, 4.dp)
        }
    }

    private fun submitRegistration() {
        val username = regUsernameInput?.text?.toString()?.trim().orEmpty()
        val password = regPasswordInput?.text?.toString().orEmpty()
        val nickname = regNicknameInput?.text?.toString()?.trim().orEmpty()
        val phone = regPhoneInput?.text?.toString()?.trim().orEmpty()

        if (username.length < 3) {
            regStatusText?.text = "用户名至少 3 个字符"
            return
        }
        if (password.length < 8) {
            regStatusText?.text = "密码至少 8 位"
            return
        }
        if (nickname.isBlank()) {
            regStatusText?.text = "请填写姓名"
            return
        }

        regStatusText?.text = "注册中..."
        sdk.userApi.register(username, password, nickname, phone) { result ->
            result.fold(
                onSuccess = { session ->
                    showingRegisterPage = false
                    saveAuthSession(session)
                },
                onFailure = { error ->
                    regStatusText?.text = "注册失败：${error.message}"
                },
            )
        }
    }

    private fun authenticate(register: Boolean) {
        val username = authUsernameInput?.text?.toString()?.trim().orEmpty()
        val password = authPasswordInput?.text?.toString().orEmpty()
        authStatusText?.text = if (register) "注册中..." else "登录中..."
        val callback = { result: Result<AuthSession> ->
            result.fold(
                onSuccess = ::saveAuthSession,
                onFailure = { error -> authStatusText?.text = "失败：${error.message}" },
            )
        }
        if (register) sdk.userApi.register(username, password, onComplete = callback)
        else sdk.userApi.login(username, password, callback)
    }

    private fun saveAuthSession(session: AuthSession) {
        authToken = session.token
        authNickname = session.user.nickname
        sdk.config.userToken = session.token
        getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit()
            .putString("token", session.token)
            .putString("nickname", session.user.nickname)
            .apply()
        activeModule = Module.COURSE
        setRootView()
        loadCatalog()
    }

    private fun logout() {
        getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit().clear().apply()
        authToken = ""
        authNickname = ""
        sdk.config.userToken = null
        controller.pause()
        activeModule = Module.TRAIN
        setRootView()
    }

    private fun createPortraitContentView(): View {
        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setBackgroundColor(COLOR_BACKGROUND)
            addView(createModuleTabBar())
            if (activeModule == Module.TEST) {
                addView(createTestContentView())
            } else {
                addView(createHeader())
                addView(createPlayerSection())
                addView(createSentenceSection())
                addView(createCatalogSection())
            }
        }
    }

    private fun createTestContentView(): View {
        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setBackgroundColor(COLOR_BACKGROUND)
            if (testSubtitleVisible) {
                addView(createPlayerSection())
                addView(createSentenceSection())
            } else {
                addView(createPlayerSection())
            }
            addView(createTestSection())
        }
    }

    private fun createModuleTabBar(): View {
        return LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            setPadding(16.dp, 40.dp, 16.dp, 0)
            addView(moduleTab("课程", Module.COURSE))
            addView(moduleTab("训练", Module.TRAIN))
            addView(moduleTab("测试", Module.TEST))
            addView(moduleTab("我的", Module.MINE))
        }
    }

    private fun moduleTab(label: String, module: Module): TextView {
        val selected = activeModule == module
        return TextView(this).apply {
            text = label
            textSize = 15f
            typeface = Typeface.DEFAULT_BOLD
            gravity = Gravity.CENTER
            isClickable = true
            setTextColor(if (selected) Color.WHITE else COLOR_TEXT_MUTED)
            background = if (selected) {
                rounded(COLOR_BUTTON, 8f)
            } else {
                rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
            }
            layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
            setOnClickListener {
            if (activeModule == module) {
                return@setOnClickListener
            }
            if (module == Module.COURSE || module == Module.MINE) {
                loadUserData()
            }
            activeModule = module
                controller.setContinuousPlayback(
                    if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false
                )
                landscapeSubtitleVisible = activeModule == Module.TRAIN || testSubtitleVisible
                setContentView(createContentView())
                renderCatalog()
                refreshCurrentUi()
                if (activeModule == Module.TEST) {
                    refreshTestUi()
                }
            }
        }
    }

    private fun createLandscapeContentView(): View {
        return FrameLayout(this).apply {
            setBackgroundColor(Color.BLACK)
            addView(createPlayerView())
            addView(createPlayerOverlay())
            val subtitleContainer = LinearLayout(this@MainActivity).apply {
                orientation = LinearLayout.VERTICAL
                layoutParams = FrameLayout.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    Gravity.BOTTOM,
                )
                addView(createLandscapeSentencePanel().apply {
                    layoutParams = LinearLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT,
                    )
                })
            }
            addView(subtitleContainer)
            applyLandscapeSubtitleState(subtitleContainer, animate = false)
            installLandscapeSwipeGesture(subtitleContainer)
        }
    }

    private fun installLandscapeSwipeGesture(subtitleContainer: View) {
        playerView.setVerticalSwipeListener { isSwipeUp ->
            if (isSwipeUp == landscapeSubtitleVisible) {
                return@setVerticalSwipeListener
            }
            landscapeSubtitleVisible = isSwipeUp
            applyLandscapeSubtitleState(subtitleContainer, animate = true)
        }
    }

    private fun applyLandscapeSubtitleState(container: View, animate: Boolean) {
        if (!animate || container.visibility == View.GONE) {
            container.clearAnimation()
            if (landscapeSubtitleVisible) {
                container.visibility = View.VISIBLE
            } else {
                container.visibility = View.GONE
            }
            return
        }
        if (landscapeSubtitleVisible) {
            container.visibility = View.VISIBLE
            container.post {
                if (landscapeSubtitleVisible && container.visibility == View.VISIBLE) {
                    startSlideAnimation(container, fromYDelta = 1f, toYDelta = 0f) {
                        container.visibility = View.VISIBLE
                    }
                }
            }
        } else {
            startSlideAnimation(container, fromYDelta = 0f, toYDelta = 1f) {
                container.visibility = View.GONE
            }
        }
    }

    private fun startSlideAnimation(
        view: View,
        fromYDelta: Float,
        toYDelta: Float,
        onEnd: () -> Unit,
    ) {
        val animation = TranslateAnimation(0f, 0f, view.height * fromYDelta, view.height * toYDelta).apply {
            duration = 220L
            fillAfter = true
            setAnimationListener(object : Animation.AnimationListener {
                override fun onAnimationStart(animation: Animation?) = Unit
                override fun onAnimationRepeat(animation: Animation?) = Unit
                override fun onAnimationEnd(animation: Animation?) {
                    view.post(onEnd)
                }
            })
        }
        view.startAnimation(animation)
    }

    private fun createLandscapeSentencePanel(): View {
        return createSentenceContent(translucent = true).apply {
            layoutParams = FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT,
                Gravity.BOTTOM,
            )
        }
    }

    private fun createHeader(): View {
        val titleBlock = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            gravity = Gravity.CENTER_VERTICAL
            layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
            addView(TextView(this@MainActivity).apply {
                text = "八哥口语"
                setTextColor(Color.WHITE)
                textSize = 32f
                typeface = Typeface.DEFAULT_BOLD
                includeFontPadding = false
            })
            addView(TextView(this@MainActivity).apply {
                text = "外语跟读训练神器"
                setTextColor(COLOR_TEXT_MUTED)
                textSize = 20f
                setPadding(1.dp, 4.dp, 0, 0)
            })
        }

        return LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            gravity = Gravity.CENTER_VERTICAL
            setPadding(18.dp, 16.dp, 18.dp, 12.dp)
            addView(titleBlock)
            addView(headerButton("导入") { openLocalVideoPicker() })
            addView(headerButton("刷新") { loadCatalog() })
        }
    }

    private fun createPlayerSection(): View {
        return FrameLayout(this).apply {
            background = rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
            clipToOutline = true
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                0,
                1f,
            ).withMargins(16.dp, 0, 16.dp, 10.dp)
            addView(createPlayerView())
            addView(createPlayerOverlay())
            addView(createRotationButton().apply {
                layoutParams = FrameLayout.LayoutParams(
                    44.dp,
                    44.dp,
                    Gravity.BOTTOM or Gravity.END,
                ).apply {
                    marginEnd = 12.dp
                    bottomMargin = 12.dp
                }
            })
        }
    }

    private fun createPlayerView(): OralTrainerPlayerView {
        return OralTrainerPlayerView(this).apply {
            bind(controller)
            layoutParams = FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT,
            )
        }.also { playerView = it }
    }

    private fun createPlayerOverlay(): View {
        val overlay = LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            gravity = Gravity.CENTER_VERTICAL
            setPadding(12.dp, 12.dp, 12.dp, 12.dp)
            layoutParams = FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT,
                Gravity.TOP,
            )
        }
        timeText = overlayPill("--:-- / --:--").apply {
            layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
        }
        speedText = overlayPill("1x")
        overlay.addView(timeText)
        overlay.addView(speedText)
        return overlay
    }

    private fun createRotationButton(): ImageButton {
        return ImageButton(this).apply {
            setImageResource(R.drawable.ic_screen_rotation)
            imageTintList = ColorStateList.valueOf(Color.WHITE)
            background = rounded(Color.argb(178, 9, 12, 16), 22.dp.toFloat())
            scaleType = ImageView.ScaleType.CENTER_INSIDE
            setPadding(10.dp, 10.dp, 10.dp, 10.dp)
            contentDescription = "横屏"
            layoutParams = LinearLayout.LayoutParams(44.dp, 44.dp)
            setOnClickListener {
                requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
            }
        }
    }

    private fun createSentenceSection(): View {
        return createSentenceContent(translucent = false).apply {
            background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER)
            setPadding(18.dp, 16.dp, 18.dp, 16.dp)
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT,
            ).withMargins(16.dp, 0, 16.dp, 12.dp)
        }
    }

    private fun createSentenceContent(translucent: Boolean): LinearLayout {
        lessonTitleText = TextView(this).apply {
            setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK)
            textSize = 17f
            typeface = Typeface.DEFAULT_BOLD
            maxLines = 1
        }
        sentenceMetaText = TextView(this).apply {
            setTextColor(if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT_DEEP)
            textSize = 13f
            setPadding(0, 8.dp, 0, 0)
        }
        sentenceText = TextView(this).apply {
            setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK)
            textSize = 22f
            typeface = Typeface.DEFAULT_BOLD
            setLineSpacing(2.dp.toFloat(), 1.05f)
            setPadding(0, 10.dp, 0, 10.dp)
        }
        progressBar = SeekBar(this).apply {
            max = PROGRESS_MAX
            progress = 0
            progressTintList = ColorStateList.valueOf(COLOR_ACCENT)
            progressBackgroundTintList = ColorStateList.valueOf(
                if (translucent) COLOR_PROGRESS_TRACK_DARK else COLOR_PROGRESS_TRACK
            )
            thumbTintList = ColorStateList.valueOf(COLOR_ACCENT)
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                28.dp,
            )
            setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
                override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
                    if (fromUser) {
                        val duration = controller.snapshot().durationMs
                        timeText.text = "${formatTime(seekPositionForProgress(progress))} / ${formatTime(duration)}"
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar) {
                    progressBarDragging = true
                }

                override fun onStopTrackingTouch(seekBar: SeekBar) {
                    progressBarDragging = false
                    controller.seekTo(seekPositionForProgress(seekBar.progress))
                }
            })
        }
        statusText = TextView(this).apply {
            setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE)
            textSize = 13f
            setPadding(0, 10.dp, 0, 0)
        }

        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            if (translucent) {
                background = rounded(COLOR_OVERLAY_FAINT, 0f, null)
                setPadding(18.dp, 14.dp, 18.dp, 16.dp)
            }
            addView(lessonTitleText)
            if (activeModule == Module.TRAIN) {
                addView(createContinuousPlaybackRow(translucent))
            }
            addView(sentenceMetaText)
            addView(sentenceText)
            addView(progressBar)
            addView(statusText)
        }
    }

    private fun createContinuousPlaybackRow(translucent: Boolean): View {
        return LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            gravity = Gravity.CENTER_VERTICAL
            setPadding(0, 6.dp, 0, 0)
            addView(TextView(this@MainActivity).apply {
                text = "连续播放"
                setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE)
                textSize = 13f
                typeface = Typeface.DEFAULT_BOLD
                layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
            })
            addView(Switch(this@MainActivity).apply {
                isChecked = continuousPlaybackEnabled
                buttonTintList = ColorStateList.valueOf(
                    if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT
                )
                setOnCheckedChangeListener { _, checked ->
                    continuousPlaybackEnabled = checked
                    controller.setContinuousPlayback(checked)
                    statusText.text = if (checked) {
                        "已开启连续播放，将按句子顺序连续播放"
                    } else {
                        "已关闭连续播放，将循环播放当前句子"
                    }
                }
            })
        }
    }

    private fun createCatalogSection(): View {
        catalogStatusText = TextView(this).apply {
            setTextColor(COLOR_TEXT_MUTED)
            textSize = 13f
            gravity = Gravity.END
            text = "正在同步"
            layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
        }
        catalogList = LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            setPadding(0, 10.dp, 0, 4.dp)
        }

        val header = LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            gravity = Gravity.CENTER_VERTICAL
            addView(TextView(this@MainActivity).apply {
                text = "课程"
                setTextColor(Color.WHITE)
                textSize = 16f
                typeface = Typeface.DEFAULT_BOLD
            })
            addView(catalogStatusText)
        }

        val scroller = HorizontalScrollView(this).apply {
            isHorizontalScrollBarEnabled = false
            addView(catalogList)
        }

        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(16.dp, 0, 16.dp, 14.dp)
            addView(header)
            addView(scroller)
        }
    }

    private fun createTestSection(): View {
        testStatusText = TextView(this).apply {
            setTextColor(COLOR_ACCENT_DEEP)
            textSize = 13f
            setPadding(0, 0, 0, 8.dp)
        }
        scoreSummaryText = TextView(this).apply {
            setTextColor(COLOR_TEXT_DARK)
            textSize = 22f
            typeface = Typeface.DEFAULT_BOLD
            setPadding(0, 8.dp, 0, 0)
            text = "尚未评测"
        }
        scoreDetailText = TextView(this).apply {
            setTextColor(COLOR_TEXT_SUBTLE)
            textSize = 13f
            setLineSpacing(4.dp.toFloat(), 1f)
            setPadding(0, 8.dp, 0, 0)
        }
        dubbingStatusText = TextView(this).apply {
            setTextColor(COLOR_ACCENT_DEEP)
            textSize = 13f
            setPadding(0, 10.dp, 0, 0)
            text = "配音：尚未生成"
        }

        val sentenceButton = { label: String, action: () -> Unit ->
            Button(this).apply {
                text = label
                isAllCaps = false
                setTextColor(Color.WHITE)
                textSize = 13f
                typeface = Typeface.DEFAULT_BOLD
                minWidth = 0
                minHeight = 0
                minimumWidth = 0
                minimumHeight = 0
                setPadding(0, 0, 0, 0)
                background = rounded(COLOR_BUTTON, 8f)
                layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0)
                setOnClickListener { action() }
            }
        }

        val dubButton: (String, () -> Unit) -> Button = { label, action ->
            Button(this).apply {
                text = label
                isAllCaps = false
                setTextColor(Color.WHITE)
                textSize = 13f
                typeface = Typeface.DEFAULT_BOLD
                minWidth = 0
                minHeight = 0
                minimumWidth = 0
                minimumHeight = 0
                background = rounded(COLOR_BUTTON, 8f)
                layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 8.dp, 8.dp, 0)
                setOnClickListener { action() }
            }
        }

        recordButton = Button(this).apply {
            text = "开始录音"
            isAllCaps = false
            setTextColor(Color.WHITE)
            textSize = 14f
            typeface = Typeface.DEFAULT_BOLD
            minWidth = 0
            minHeight = 0
            minimumWidth = 0
            minimumHeight = 0
            background = rounded(COLOR_ACCENT, 8f)
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                44.dp,
                1f,
            )
            setOnClickListener {
                if (mediaRecorder == null) {
                    startRecording()
                } else {
                    stopRecordingAndAssess()
                }
            }
        }

        val subtitleToggle = LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            gravity = Gravity.CENTER_VERTICAL
            setPadding(0, 10.dp, 0, 0)
            addView(TextView(this@MainActivity).apply {
                text = "显示字幕"
                setTextColor(COLOR_TEXT_DARK)
                textSize = 13f
                typeface = Typeface.DEFAULT_BOLD
                layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
            })
            addView(Switch(this@MainActivity).apply {
                isChecked = testSubtitleVisible
                buttonTintList = ColorStateList.valueOf(COLOR_ACCENT)
                setOnCheckedChangeListener { _, checked ->
                    testSubtitleVisible = checked
                    landscapeSubtitleVisible = checked
                    setContentView(createContentView())
                    refreshCurrentUi()
                    refreshTestUi()
                }
            })
        }

        playDubButton = dubButton("播放配音", {}).apply { isEnabled = false }
        mergeDubButton = dubButton("生成配音", {}).apply { isEnabled = false }
        shareDubButton = dubButton("分享配音", {}).apply { isEnabled = false }
        playDubButton.setOnClickListener { toggleDubPlayback() }
        mergeDubButton.setOnClickListener { mergeDubSegments() }
        shareDubButton.setOnClickListener { shareMergedDubbing() }

        val controlRow = LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            addView(sentenceButton("上一句") { controller.seekToPreviousSentence() })
            addView(sentenceButton("播放原句") {
                controller.playCurrentSentenceAndStop()
                statusText.text = "正在播放当前句子，播放完自动停止"
            })
            addView(sentenceButton("下一句") { controller.seekToNextSentence() })
        }

        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER)
            setPadding(18.dp, 14.dp, 18.dp, 16.dp)
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT,
            ).withMargins(16.dp, 0, 16.dp, 12.dp)
            addView(TextView(this@MainActivity).apply {
                text = "朗读评测（测试）"
                setTextColor(COLOR_TEXT_DARK)
                textSize = 16f
                typeface = Typeface.DEFAULT_BOLD
            })
            addView(subtitleToggle, 0)
            addView(testStatusText)
            addView(controlRow)
            addView(recordButton)
            addView(LinearLayout(this@MainActivity).apply {
                orientation = LinearLayout.HORIZONTAL
                addView(playDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 8.dp, 0) })
                addView(mergeDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 8.dp, 0) })
                addView(shareDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 0, 0) })
            })
            addView(scoreSummaryText)
            addView(scoreDetailText)
            addView(dubbingStatusText)
        }
    }

    private fun setDubbingStatus(message: String) {
        dubbingStatusText?.text = message
    }

    private fun refreshTestUi() {
        if (!::testStatusText.isInitialized) {
            return
        }
        applyTestSubtitleVisibility()
        recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测"
        val item = controller.currentTrainingItem()
        val sentence = controller.currentSentence()
        val currentRecording = sentence?.let { dubSegments[it.index] }
        val segmentLabel = when {
            currentRecording != null -> " · 本句已录音，重录将覆盖"
            else -> ""
        }
        testStatusText.text = if (item == null || sentence == null) {
            "请先在训练模块选择课程$segmentLabel"
        } else {
            "评测对象：第 ${sentence.index + 1} 句（共 ${item.sentences.size} 句）$segmentLabel"
        }
        refreshDubButtons()
        if (mergedDubFile == null) {
            dubbingStatusText?.text = when (dubSegments.size) {
                0 -> "配音：请先录音"
                1 -> "配音：已保存 1 句，可生成配音"
                else -> "配音：已保存 ${dubSegments.size} 句，可生成配音"
            }
        }
    }

    private fun applyTestSubtitleVisibility() {
        if (activeModule != Module.TEST) {
            return
        }
        landscapeSubtitleVisible = testSubtitleVisible
        if (!::sentenceText.isInitialized || !::progressBar.isInitialized) {
            return
        }
        val visibility = if (testSubtitleVisible) View.VISIBLE else View.GONE
        sentenceMetaText.visibility = visibility
        sentenceText.visibility = visibility
        progressBar.visibility = visibility
    }

    private fun startRecording() {
        controller.pause()
        if (!::testStatusText.isInitialized) {
            return
        }
        if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RECORD_AUDIO_REQUEST)
            return
        }
        val file = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a")
        val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            MediaRecorder(this)
        } else {
            @Suppress("DEPRECATION")
            MediaRecorder()
        }
        try {
            recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
            recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
            recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
            recorder.setAudioSamplingRate(16_000)
            recorder.setAudioEncodingBitRate(96_000)
            recorder.setOutputFile(file.absolutePath)
            recorder.prepare()
            recorder.start()
            val sentence = controller.currentSentence()
            if (sentence != null) {
                controller.seekTo(sentence.startMs)
                controller.setVolume(0f)
            }
            controller.play()
            mediaRecorder = recorder
            recordingFile = file
            recordButton.text = "停止并保存"
            testStatusText.text = if (sentence == null) {
                "正在录音…读完当前句子后点击“停止并保存”"
            } else {
                "正在录音…请对照静音视频口形，读完点击“停止并保存”"
            }
        } catch (error: Throwable) {
            testStatusText.text = "录音启动失败：${error.message.orEmpty()}"
            runCatching { recorder.release() }
        }
    }

    private fun stopRecordingAndAssess() {
        val recorder = mediaRecorder ?: return
        val file = recordingFile ?: return
        mediaRecorder = null
        recordButton.text = "重新录音"
        try {
            recorder.stop()
        } catch (error: Throwable) {
            testStatusText.text = "录音太短或无法保存：${error.message.orEmpty()}"
            file.delete()
            recordingFile = null
            return
        } finally {
            runCatching { recorder.release() }
        }
        controller.pause()
        controller.setVolume(1f)
        val sentenceIndex = controller.currentSentence()?.index
        if (sentenceIndex == null) {
            testStatusText.text = "没有可归属的句子，本次录音已丢弃"
            file.delete()
            recordingFile = null
            refreshTestUi()
            return
        }
        dubSegments[sentenceIndex]?.delete()
        dubSegments[sentenceIndex] = file
        mergedDubFile = null
        recordingFile = null
        submitAssessment(Uri.fromFile(file))
        refreshTestUi()
    }

    private fun discardRecording() {
        mediaRecorder?.let { recorder ->
            runCatching { recorder.stop() }
            runCatching { recorder.release() }
        }
        mediaRecorder = null
        recordingFile?.delete()
        recordingFile = null
    }

    private fun releaseDubbingResources() {
        discardRecording()
        dubPlayerListener?.let { listener -> dubPlayer?.removeListener(listener) }
        dubPlayer?.release()
        dubPlayerListener = null
        dubPlayer = null
    }

    private fun refreshDubButtons() {
        playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
        mergeDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback
        shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback
        val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] }
        playDubButton.text = when {
            dubbingPlayback -> "停止配音"
            mergedDubFile?.exists() == true -> "播放配音"
            activeAudio != null -> "播放本句配音"
            else -> "播放录音（${dubSegments.size}）"
        }
    }

    private fun toggleDubPlayback() {
        if (dubbingPlayback) {
            stopDubPlayback("已停止配音播放")
            return
        }
        startDubPlayback(mergedDubFile ?: activeAudioForPlayback() ?: return)
    }

    private fun activeAudioForPlayback(): File? {
        if (mergedDubFile != null) {
            return mergedDubFile
        }
        return controller.currentSentence()?.let { sentence -> dubSegments[sentence.index] }
    }

    private fun startDubPlayback(audioFile: File) {
        val sentence = controller.currentSentence()
        val player = dubPlayer ?: ExoPlayer.Builder(this).build().also { dubPlayer = it }
        dubPlayerListener?.let(player::removeListener)
        val playbackGeneration = ++dubPlaybackGeneration
        player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
        player.prepare()
        player.volume = 1f
        if (mergedDubFile == null && sentence != null) {
            controller.seekTo(sentence.startMs)
        }
        controller.setVolume(0f)
        controller.play()
        player.playWhenReady = true
        dubbingPlayback = true
        dubPlayerListener = object : Player.Listener {
            override fun onPlaybackStateChanged(playbackState: Int) {
                if (playbackState == Player.STATE_ENDED && playbackGeneration == dubPlaybackGeneration) {
                    mainHandler.postDelayed({
                        if (dubbingPlayback && playbackGeneration == dubPlaybackGeneration) {
                            stopDubPlayback("配音播放完成")
                        }
                    }, 250L)
                }
            }
        }
        player.addListener(dubPlayerListener!!)
        refreshTestUi()
        statusText.text = if (mergedDubFile == null) {
            "正在播放当前句配音，原视频画面同步但原声静音"
        } else {
            "正在播放整段配音，原视频画面同步但原声静音"
        }
    }

    private fun stopDubPlayback(message: String) {
        dubPlayer?.pause()
        dubPlayer?.seekTo(0L)
        controller.pause()
        controller.setVolume(1f)
        dubbingPlayback = false
        dubPlaybackGeneration++
        refreshTestUi()
        statusText.text = message
    }

    private fun mergeDubSegments() {
        val segments = dubSegments.toSortedMap().values.toList()
        if (segments.isEmpty()) {
            return
        }
        val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
        var muxer: MediaMuxer? = null
        try {
            muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
            var muxerAudioTrack = -1
            var muxerStarted = false
            var presentationTimeUs = 0L

            segments.forEach { segment ->
                val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
                try {
                    val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
                        extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)
                            ?.startsWith("audio/") == true
                    } ?: throw IllegalStateException("录音没有音频轨道")
                    extractor.selectTrack(trackIndex)
                    val format = extractor.getTrackFormat(trackIndex)
                    if (muxerAudioTrack == -1) {
                        muxerAudioTrack = muxer.addTrack(format)
                        muxer.start()
                        muxerStarted = true
                    }
                    val buffer = ByteBuffer.allocateDirect(
                        if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
                            format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
                        } else {
                            64 * 1024
                        }
                    )
                    val info = MediaCodec.BufferInfo()
                    while (true) {
                        val sampleSize = extractor.readSampleData(buffer, 0)
                        if (sampleSize < 0) break
                        info.set(0, sampleSize, presentationTimeUs, extractor.sampleFlags)
                        muxer.writeSampleData(muxerAudioTrack, buffer, info)
                        presentationTimeUs += 23_000L
                        extractor.advance()
                    }
                    val durationUs = segmentDurationUs(segment)
                    require(durationUs > 0L) { "无法读取录音时长" }
                    presentationTimeUs = max(presentationTimeUs, durationUs)
                } finally {
                    extractor.release()
                }
            }
            require(muxerStarted && muxerAudioTrack != -1) { "录音没有可用音频轨道" }
            muxer.stop()
            muxer.release()
            muxer = null
            mergedDubFile = output
            refreshTestUi()
            setDubbingStatus("已合成 ${segments.size} 句配音，可点击“分享配音”")
        } catch (error: Throwable) {
            runCatching { muxer?.stop() }
            runCatching { muxer?.release() }
            output.delete()
            Log.e(TAG_DUBBING, "merge failed", error)
            setDubbingStatus(
                buildString {
                    append("生成失败：${error.javaClass.simpleName}: ${error.message.orEmpty()}")
                    append(" · 已保存 ${dubSegments.size} 句")
                }
            )
        }
    }

    private fun segmentDurationUs(segment: File): Long {
        val retriever = MediaMetadataRetriever()
        return try {
            retriever.setDataSource(segment.absolutePath)
            val durationMs = retriever.extractMetadata(
                MediaMetadataRetriever.METADATA_KEY_DURATION
            )?.toLongOrNull() ?: return 0L
            durationMs * 1000L
        } catch (error: Throwable) {
            0L
        } finally {
            runCatching { retriever.release() }
        }
    }

    private fun shareMergedDubbing() {
        val item = controller.currentTrainingItem()
        if (item == null || !SHA256_PATTERN.matches(item.id)) {
            setDubbingStatus("分享失败：网页分享仅支持云端课程视频")
            return
        }
        try {
            val shareUrl = uploadDubShare(item.id, item.title)
            val intent = Intent(Intent.ACTION_SEND).apply {
                type = "text/plain"
                putExtra(Intent.EXTRA_TEXT, "我的口语配音：$shareUrl")
                putExtra(Intent.EXTRA_TITLE, "我的口语配音")
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            }
            startActivity(Intent.createChooser(intent, "分享配音"))
            setDubbingStatus("已生成分享链接：$shareUrl")
        } catch (error: Throwable) {
            Log.e(TAG_DUBBING, "share failed", error)
            setDubbingStatus("分享失败：${error.javaClass.simpleName}: ${error.message.orEmpty()}")
        }
    }

    private fun uploadDubShare(videoHash: String, videoTitle: String): String {
        val policy = StrictMode.getThreadPolicy()
        StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
        val boundary = "oral-trainer-${System.currentTimeMillis()}"
        var connection: HttpURLConnection? = null
        try {
            connection = URL(URL(SERVER_BASE_URL), "/api/v1/dub-shares").openConnection() as HttpURLConnection
            connection.requestMethod = "POST"
            connection.doOutput = true
            connection.connectTimeout = 15_000
            connection.readTimeout = 120_000
            connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")

            DataOutputStream(connection.outputStream).use { output ->
                fun text(name: String, value: String) {
                    output.writeBytes("--$boundary\r\n")
                    output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
                    output.writeBytes(value.replace("\n", "\\n") + "\r\n")
                }
                text("video_hash", videoHash.lowercase(Locale.US))
                text("title", videoTitle.ifBlank { "我的口语配音" })
                // Build explicitly to avoid fragile string replacement.
                val payload = dubSegments.keys.sorted().joinToString(",", "[", "]") {
                    "{\"sentence_index\":$it}"
                }
                text("segments", payload)
                val scores = dubSegments.keys.sorted().mapNotNull { index ->
                    latestScores[index]
                    ?.let { score -> index to score }
                }.joinToString(",", "[", "]") { (index, score) ->
                    buildString {
                        append("{\"sentence_index\":")
                        append(index)
                        if (score.overallScore != null) {
                            append(",\"overall_score\":")
                            append(score.overallScore)
                        }
                        listOf(
                            "content_score" to score.contentScore,
                            "fluency_score" to score.fluencyScore,
                            "duration_score" to score.durationScore,
                            "pause_score" to score.pauseScore,
                            "speech_rate_score" to score.speechRateScore,
                        ).forEach { (name, value) ->
                            value?.let {
                                append(",\"$name\":")
                                append(it)
                            }
                        }
                        score.recognizedText?.let { text ->
                            append(",\"recognized_text\":\"")
                            append(text.replace("\\", "\\\\").replace("\"", "\\\""))
                            append("\"")
                        }
                        append("}")
                    }
                }
                text("scores", scores)
                dubSegments.toSortedMap().forEach { (index, file) ->
                    output.writeBytes("--$boundary\r\n")
                    output.writeBytes(
                        "Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
                    )
                    output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
                    file.inputStream().use { input -> input.copyTo(output) }
                    output.writeBytes("\r\n")
                }
                output.writeBytes("--$boundary--\r\n")
            }

            val code = connection.responseCode
            val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
                ?.bufferedReader()?.use { it.readText() }.orEmpty()
            require(code in 200..299) { "HTTP $code: $body" }
            val response = JSONObject(body).getString("share_id")
            return "$SERVER_BASE_URL/dub-shares/$response"
        } finally {
            connection?.disconnect()
            StrictMode.setThreadPolicy(policy)
        }
    }

    private fun submitAssessment(recordingUri: Uri) {
        val item = controller.currentTrainingItem()
        val sentence = controller.currentSentence()
        if (item == null || sentence == null) {
            testStatusText.text = "没有正在学习的句子，无法评测"
            return
        }
        if (!SHA256_PATTERN.matches(item.id)) {
            testStatusText.text = "仅云端课程支持朗读评测（本地视频请先上传到服务器）"
            return
        }
        if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) {
            testStatusText.text = "未配置评分密钥：请在 MainActivity 填入服务器 .env 的 CLIENT_API_KEY"
            return
        }
        testStatusText.text = "正在评测第 ${sentence.index + 1} 句，请稍候…"
        scoreSummaryText.text = "评测中…"
        scoreDetailText.text = ""
        controller.assessCurrentSentence(
            recordingUri = recordingUri,
            locale = sentence.language
                ?.takeIf { it.isNotBlank() }
                ?: item.sentences.getOrNull(sentence.index)?.language,
            metadata = mapOf("video_hash" to item.id),
            callback = object : ImitationAssessmentCallback {
                override fun onResult(result: ImitationAssessmentResult) {
                    if (::testStatusText.isInitialized) {
                        applyAssessmentResult(result)
                    }
                    sentence.index.let { index ->
                        latestScores[index] = result
                    }
                }

                override fun onError(error: Throwable) {
                    if (::testStatusText.isInitialized) {
                        testStatusText.text = "评测失败：${error.message.orEmpty()}"
                        scoreSummaryText.text = "评测失败"
                    }
                }
            },
        )
    }

    private fun applyAssessmentResult(result: ImitationAssessmentResult) {
        testStatusText.text = if (result.passed == true) "已通过" else "未通过"
        scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
        scoreDetailText.text = buildString {
            result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) }
            result.fluencyScore?.let { appendLine(String.format(Locale.US, "流畅度 %.1f", it)) }
            result.durationScore?.let { appendLine(String.format(Locale.US, "时长分 %.1f", it)) }
            result.pauseScore?.let { appendLine(String.format(Locale.US, "停顿分 %.1f", it)) }
            result.speechRateScore?.let { appendLine(String.format(Locale.US, "语速分 %.1f", it)) }
            result.referenceText?.let { appendLine("参考：$it") }
            result.recognizedText?.let { appendLine("识别：$it") }
            if (result.missingTokens.isNotEmpty()) {
                appendLine("漏读：${result.missingTokens.joinToString("、")}")
            }
            if (result.extraTokens.isNotEmpty()) {
                appendLine("多读：${result.extraTokens.joinToString("、")}")
            }
            result.feedback?.let { appendLine(it) }
        }.trimEnd()
    }

    private fun createControllerListener(): OralTrainerListener {
        return object : OralTrainerListener {
            override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
                applyPlaybackSnapshot(snapshot)
            }

            override fun onMediaChanged(item: TrainingMediaItem?) {
                if (!::lessonTitleText.isInitialized) return
                lessonTitleText.text = item?.title ?: "未选择课程"
                currentSentenceCount = item?.sentences?.size ?: 0
            }

            override fun onSentenceChanged(sentence: SentenceBoundary?) {
                applySentence(sentence)
            }

            override fun onGesture(event: GestureEvent) {
                if (!::statusText.isInitialized) return
                statusText.text = when (event.kind) {
                    GestureKind.SINGLE_TAP -> "播放状态已切换"
                    GestureKind.SWIPE_LEFT -> "已跳到上一句"
                    GestureKind.SWIPE_RIGHT -> "已跳到下一句"
                    GestureKind.LONG_PRESS_SPEED -> "变速播放中"
                }
            }

            override fun onPlayerError(error: Throwable) {
                if (!::statusText.isInitialized) return
                statusText.text = "播放失败：${error.message.orEmpty()}"
            }
        }
    }

    private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) {
        if (!::timeText.isInitialized || !::progressBar.isInitialized ||
            !::speedText.isInitialized || !::statusText.isInitialized
        ) {
            return
        }
        if (!progressBarDragging) {
            timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
            progressBar.progress = playbackProgress(snapshot)
        }
        speedText.text = formatSpeed(snapshot.playbackSpeed)
        statusText.text = playbackStatus(snapshot)
    }

    private fun seekPositionForProgress(progress: Int): Long {
        val duration = controller.snapshot().durationMs
        if (duration <= 0L) {
            return 0L
        }
        return progress.toLong() * duration / PROGRESS_MAX
    }

    private fun applySentence(sentence: SentenceBoundary?) {
        if (!::sentenceMetaText.isInitialized || !::sentenceText.isInitialized ||
            !::lessonTitleText.isInitialized
        ) {
            return
        }
        if (sentence == null) {
            sentenceMetaText.text = "暂无句子边界"
            sentenceText.text = lessonTitleText.text
            return
        }
        val countText = if (currentSentenceCount > 0) {
            " / $currentSentenceCount"
        } else {
            ""
        }
        sentenceMetaText.text = "第 ${sentence.index + 1}$countText 句  ${formatTime(sentence.startMs)}-${formatTime(sentence.endMs)}"
        sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
    }

    private fun refreshCurrentUi() {
        if (activeModule != Module.TRAIN && activeModule != Module.TEST) return
        if (!::lessonTitleText.isInitialized) return
        val item = controller.currentTrainingItem()
        lessonTitleText.text = item?.title ?: "未选择课程"
        currentSentenceCount = item?.sentences?.size ?: 0
        applyPlaybackSnapshot(controller.snapshot())
        applySentence(controller.currentSentence())
        if (::catalogStatusText.isInitialized) {
            catalogStatusText.text = catalogStatusTextValue
        }
    }

    private fun loadCatalog() {
        if (!isLoggedIn()) return
        catalogStatusTextValue = "正在同步"
        if (::catalogStatusText.isInitialized) {
            catalogStatusText.text = catalogStatusTextValue
        }
        if (::catalogList.isInitialized) {
            catalogList.removeAllViews()
            catalogList.addView(infoCard("课程加载中..."))
        }
        sdk.userApi.courses(authToken) { result ->
            result.fold(
                onSuccess = { courses ->
                    catalogVideos = courses.map { it.toVideoSummary() }
                    courseEnrollment.clear()
                    courses.forEach { courseEnrollment[it.videoHash] = it.enrolled }
                    catalogStatusTextValue = "${catalogVideos.size} 个课程"
                    if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
                    renderCatalog()
                },
                onFailure = {
                    catalogStatusTextValue = "课程加载失败：${it.message}"
                    if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue
                    courseEnrollment.clear()
                    renderCatalog()
                },
            )
        }
    }

    private fun loadLegacyCatalog() {
        sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
            override fun onSuccess(videos: List<TrainingVideoSummary>) {
                catalogVideos = videos
                catalogStatusTextValue = if (videos.isEmpty()) {
                    "暂无云端课程"
                } else {
                    "${videos.size} 个云端课程"
                }
                if (::catalogStatusText.isInitialized) {
                    catalogStatusText.text = catalogStatusTextValue
                }
                renderCatalog()
                catalogVideos
                    .firstOrNull { it.videoHash == activeItemId }
                    ?.let { refreshSentenceBoundaries(it, background = true) }
            }

            override fun onError(error: Throwable) {
                catalogStatusTextValue = "云端暂不可用：${error.message.orEmpty()}"
                if (::catalogStatusText.isInitialized) {
                    catalogStatusText.text = catalogStatusTextValue
                }
                renderCatalog()
            }
        })
    }

    private fun loadUserData() {
        when (activeModule) {
            Module.COURSE -> loadCatalog()
            Module.MINE -> refreshMyContent()
            else -> Unit
        }
    }

    private fun createCourseSection(): View {
        if (!::catalogList.isInitialized) {
            catalogList = LinearLayout(this).apply {
                orientation = LinearLayout.VERTICAL
            }
        }
        return ScrollView(this).apply {
            layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
            addView(LinearLayout(this@MainActivity).apply {
                orientation = LinearLayout.VERTICAL
                setPadding(16.dp, 12.dp, 16.dp, 16.dp)
                addView(TextView(this@MainActivity).apply {
                    text = "课程首页"
                    textSize = 26f
                    typeface = Typeface.DEFAULT_BOLD
                    setTextColor(Color.WHITE)
                })
                addView(TextView(this@MainActivity).apply {
                    text = "选择一门课程开始训练"
                    textSize = 13f
                    setTextColor(COLOR_TEXT_MUTED)
                    setPadding(0, 4.dp, 0, 16.dp)
                })
                addView(catalogList)
            })
        }
    }

    private fun createMySection(): View {
        myContentList = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }
        return ScrollView(this).apply {
            layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
            addView(LinearLayout(this@MainActivity).apply {
                orientation = LinearLayout.VERTICAL
                setPadding(16.dp, 12.dp, 16.dp, 16.dp)
                addView(TextView(this@MainActivity).apply {
                    text = authNickname.ifBlank { "我的学习" }
                    textSize = 26f
                    typeface = Typeface.DEFAULT_BOLD
                    setTextColor(Color.WHITE)
                })
                addView(Button(this@MainActivity).apply {
                    text = "退出登录"
                    setOnClickListener { logout() }
                    layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 12.dp, 0, 20.dp)
                })
                addView(TextView(this@MainActivity).apply {
                    text = "我的测试结果"
                    textSize = 18f
                    typeface = Typeface.DEFAULT_BOLD
                    setTextColor(Color.WHITE)
                })
                addView(myContentList)
            })
        }
    }

    private fun refreshMyContent() {
        val list = myContentList ?: return
        list.removeAllViews()
        list.addView(infoCard("加载中..."))
        sdk.userApi.results(authToken) { result ->
            list.removeAllViews()
            result.fold(
                onSuccess = { rows ->
                    if (rows.isEmpty()) list.addView(infoCard("还没有测试记录"))
                    rows.forEach { row ->
                        list.addView(infoCard(
                            "${row.courseTitle} · 第 ${row.sentenceIndex + 1} 句\n" +
                                "得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}"
                        ))
                    }
                },
                onFailure = { list.addView(infoCard("测试记录加载失败：${it.message}")) },
            )
            sdk.userApi.dubShares(authToken) { shareResult ->
                list.post {
                    list.addView(titleDivider("我的配音分享"))
                    shareResult.fold(
                        onSuccess = { shares ->
                            if (shares.isEmpty()) list.addView(infoCard("还没有分享配音"))
                            shares.forEach { row ->
                                list.addView(infoCard(
                                    "${row.title}\n${row.courseTitle} · ${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分"
                                ))
                            }
                        },
                        onFailure = { list.addView(infoCard("配音分享加载失败：${it.message}")) },
                    )
                }
            }
        }
    }

    private fun infoCard(text: String): TextView {
        return TextView(this).apply {
            this.text = text
            setTextColor(COLOR_TEXT_DARK)
            textSize = 13f
            background = rounded(COLOR_SELECTED, 8f)
            setPadding(12.dp, 10.dp, 12.dp, 10.dp)
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT,
            ).withMargins(0, 0, 0, 8.dp)
        }
    }

    private fun titleDivider(text: String): TextView {
        return TextView(this).apply {
            this.text = text
            setTextColor(Color.WHITE)
            textSize = 18f
            typeface = Typeface.DEFAULT_BOLD
            setPadding(0, 20.dp, 0, 8.dp)
        }
    }

    private fun renderCatalog() {
        if (!::catalogList.isInitialized) {
            return
        }
        catalogList.removeAllViews()
        catalogVideos.forEach { video ->
            val enrolled = courseEnrollment[video.videoHash] == true
            catalogList.addView(
                videoCard(
                    id = video.videoHash,
                    title = video.title,
                    meta = if (enrolled) {
                        "已选修 · ${video.sentenceCount} 句"
                    } else {
                        "未选修 · 点击选修"
                    },
                ) {
                    if (enrolled) loadRemoteVideo(video) else enrollCourse(video)
                }
            )
        }
    }

    private fun enrollCourse(video: TrainingVideoSummary) {
        sdk.userApi.enroll(authToken, video.videoHash) { result ->
            result.fold(
                onSuccess = {
                    courseEnrollment[video.videoHash] = true
                    renderCatalog()
                },
                onFailure = {
                    if (::catalogStatusText.isInitialized) {
                        catalogStatusText.text = "选修失败：${it.message}"
                    }
                },
            )
        }
    }

    private fun loadRemoteVideo(video: TrainingVideoSummary) {
        activeItemId = video.videoHash
        if (::lessonTitleText.isInitialized) lessonTitleText.text = video.title
        if (::sentenceMetaText.isInitialized) sentenceMetaText.text = "句子边界加载中"
        if (::sentenceText.isInitialized) sentenceText.text = video.title
        if (::statusText.isInitialized) statusText.text = "正在加载云端课程..."
        renderCatalog()

        val loadingItem = video.toTrainingMediaItem()
        controller.loadItem(loadingItem)
        currentSentenceCount = video.sentenceCount
        refreshSentenceBoundaries(video, background = false)
    }

    private fun refreshSentenceBoundaries(
        video: TrainingVideoSummary,
        background: Boolean,
    ) {
        val requestGeneration = ++sentenceBoundaryRequestGeneration
        if (!background && ::statusText.isInitialized) {
            statusText.text = "正在加载云端课程..."
        } else if (::statusText.isInitialized) {
            statusText.text = "正在刷新句子边界..."
        }
        sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
            override fun onSuccess(result: SentenceBoundaryApiResult) {
                if (
                    sentenceBoundaryRequestGeneration != requestGeneration ||
                    activeItemId != video.videoHash
                ) {
                    return
                }
                currentSentenceCount = result.sentences.size
                controller.updateSentences(video.videoHash, result.sentences)
                refreshCurrentUi()
                statusText.text = "课程已就绪：${result.sentences.size} 句"
            }

            override fun onError(error: Throwable) {
                if (
                    sentenceBoundaryRequestGeneration != requestGeneration ||
                    activeItemId != video.videoHash
                ) {
                    return
                }
                statusText.text = "句子边界加载失败：${error.message.orEmpty()}"
                sentenceMetaText.text = "暂无句子边界"
                sentenceText.text = video.title
            }
        })
    }

    private fun videoCard(
        id: String,
        title: String,
        meta: String,
        onClick: () -> Unit,
    ): View {
        val selected = activeItemId == id
        val titleColor = if (selected) COLOR_TEXT_DARK else Color.WHITE
        val metaColor = if (selected) COLOR_ACCENT_DEEP else COLOR_TEXT_MUTED
        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            gravity = Gravity.CENTER_VERTICAL
            isClickable = true
            background = if (selected) {
                rounded(COLOR_SELECTED, 8f, COLOR_ACCENT)
            } else {
                rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
            }
            setPadding(14.dp, 12.dp, 14.dp, 12.dp)
            layoutParams = LinearLayout.LayoutParams(218.dp, 84.dp).withMargins(0, 0, 10.dp, 0)
            setOnClickListener { onClick() }
            addView(TextView(this@MainActivity).apply {
                text = title
                setTextColor(titleColor)
                textSize = 15f
                typeface = Typeface.DEFAULT_BOLD
                maxLines = 2
            })
            addView(TextView(this@MainActivity).apply {
                text = meta
                setTextColor(metaColor)
                textSize = 12f
                setPadding(0, 6.dp, 0, 0)
            })
        }
    }

    private fun headerButton(label: String, action: () -> Unit): Button {
        return Button(this).apply {
            text = label
            isAllCaps = false
            setTextColor(Color.WHITE)
            textSize = 14f
            typeface = Typeface.DEFAULT_BOLD
            minWidth = 0
            minHeight = 0
            minimumWidth = 0
            minimumHeight = 0
            setPadding(14.dp, 0, 14.dp, 0)
            background = rounded(COLOR_BUTTON, 8f)
            layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                40.dp,
            ).withMargins(8.dp, 0, 0, 0)
            setOnClickListener { action() }
        }
    }

    private fun overlayPill(label: String): TextView {
        return TextView(this).apply {
            text = label
            setTextColor(Color.WHITE)
            textSize = 13f
            typeface = Typeface.DEFAULT_BOLD
            background = rounded(COLOR_OVERLAY_FAINT, 8f)
            setPadding(10.dp, 6.dp, 10.dp, 6.dp)
            gravity = Gravity.CENTER
        }
    }

    private fun openLocalVideoPicker() {
        startActivityForResult(
            Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
                addCategory(Intent.CATEGORY_OPENABLE)
                type = "video/*"
                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
            },
            PICK_VIDEO_REQUEST,
        )
    }

    private fun playbackStatus(snapshot: PlaybackSnapshot): String {
        val state = when {
            snapshot.isPlaying -> "播放中"
            snapshot.playbackState.name == "BUFFERING" -> "缓冲中"
            snapshot.playbackState.name == "ENDED" -> "已结束"
            else -> "已暂停"
        }
        val sentence = snapshot.sentenceIndex?.let { " · 第 ${it + 1} 句" }.orEmpty()
        return "$state$sentence"
    }

    private fun playbackProgress(snapshot: PlaybackSnapshot): Int {
        if (snapshot.durationMs <= 0L) {
            return 0
        }
        return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * PROGRESS_MAX) / snapshot.durationMs).toInt()
    }

    private fun formatTime(ms: Long): String {
        if (ms < 0) {
            return "--:--"
        }
        val totalSeconds = ms / 1000
        val hours = totalSeconds / 3600
        val minutes = (totalSeconds % 3600) / 60
        val seconds = totalSeconds % 60
        return if (hours > 0) {
            "%d:%02d:%02d".format(Locale.US, hours, minutes, seconds)
        } else {
            "%02d:%02d".format(Locale.US, minutes, seconds)
        }
    }

    private fun formatSpeed(speed: Float): String {
        val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.')
        return "${value}x"
    }

    private fun rounded(
        color: Int,
        radiusDp: Float,
        strokeColor: Int? = null,
    ): GradientDrawable {
        return GradientDrawable().apply {
            setColor(color)
            cornerRadius = radiusDp.dp.toFloat()
            strokeColor?.let { setStroke(1.dp, it) }
        }
    }

    private fun LinearLayout.LayoutParams.withMargins(
        left: Int,
        top: Int,
        right: Int,
        bottom: Int,
    ): LinearLayout.LayoutParams {
        setMargins(left, top, right, bottom)
        return this
    }

    private val Int.dp: Int
        get() = (this * resources.displayMetrics.density).toInt()

    private val Float.dp: Int
        get() = (this * resources.displayMetrics.density).toInt()

    private companion object {
        // 服务端地址：改成你实际部署的域名或 IP。
        // 内网/开发环境可填 http://<服务器IP>:<端口>（例如 http://192.168.1.100:80）。
        const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
        // 朗读评测密钥：填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
        const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"

        const val PICK_VIDEO_REQUEST = 1001
        const val RECORD_AUDIO_REQUEST = 1002
        const val PROGRESS_MAX = 1000
        private const val TAG_DUBBING = "Dubbing"

        private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")

        enum class Module {
            COURSE,
            TRAIN,
            TEST,
            MINE,
        }

        val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)
        val COLOR_SURFACE: Int = Color.rgb(28, 34, 40)
        val COLOR_BORDER: Int = Color.rgb(50, 59, 67)
        val COLOR_BUTTON: Int = Color.rgb(37, 99, 235)
        val COLOR_ACCENT: Int = Color.rgb(21, 184, 132)
        val COLOR_ACCENT_DEEP: Int = Color.rgb(7, 118, 86)
        val COLOR_ACCENT_LIGHT: Int = Color.rgb(94, 234, 182)
        val COLOR_SELECTED: Int = Color.rgb(229, 248, 240)
        val COLOR_PROGRESS_TRACK: Int = Color.rgb(224, 231, 235)
        val COLOR_PROGRESS_TRACK_DARK: Int = Color.rgb(55, 62, 70)
        val COLOR_LIGHT_BORDER: Int = Color.rgb(218, 226, 232)
        val COLOR_OVERLAY: Int = Color.argb(178, 9, 12, 16)
        val COLOR_OVERLAY_FAINT: Int = Color.argb(51, 9, 12, 16)
        val COLOR_TEXT_DARK: Int = Color.rgb(18, 24, 31)
        val COLOR_TEXT_MUTED: Int = Color.rgb(151, 162, 174)
        val COLOR_TEXT_SUBTLE: Int = Color.rgb(88, 98, 108)
        val COLOR_TEXT_OVERLAY: Int = Color.rgb(163, 172, 182)
    }
}
