package com.tomhempel.rbarometer.questionnaire

import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import com.tomhempel.rbarometer.LanguageManager
import com.tomhempel.rbarometer.MainActivity
import com.tomhempel.rbarometer.R
import com.tomhempel.rbarometer.network.TokenStore
import com.tomhempel.rbarometer.questionnaire.handlers.HandlerStringSpinner
import com.tomhempel.rbarometer.questionnaire.handlers.HandlerValueSpinner
import com.tomhempel.rbarometer.ui.AppLanguageStore
import com.tomhempel.rbarometer.ui.ReviewScoresHandler
import com.tomhempel.rbarometer.utils.DateAnswerFormat
import com.tomhempel.rbarometer.utils.DatePrecision
import com.tomhempel.rbarometer.utils.ScreenLoading
import com.tomhempel.rbarometer.utils.TextInputSanitizer
import com.tomhempel.rbarometer.utils.ViewUtils
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import kotlinx.coroutines.*
import java.util.Calendar

class QuestionnaireAllInOne(private val questionnaireFileName: String) : QuestionnaireBase<Unit>() {

    private companion object {
        /** First frame inflates one section for fast reveal; subsequent batches use [sectionsPerFrame]. */
        const val INITIAL_SECTIONS_PER_FRAME = 1
    }

    private data class Section(
        val index: Int,
        val question: QuestionItem,
        val card: MaterialCardView,
        val sectionView: View,
        val handler: QuestionHandler?
    )

    private val sections = mutableListOf<Section>()
    private lateinit var container: LinearLayout
    private lateinit var btnSave: MaterialButton
    private lateinit var btnBack: MaterialButton
    private lateinit var contentLoadingOverlay: View
    private lateinit var scrollContainer: View
    private var setupComplete = false
    private var initialSetupComplete = false
    private var uiRevealed = false
    private var suppressSpinnerEvents = false
    /** Answers as loaded from storage when editing started; not reset during branch reinflation. */
    private var persistedBaselineAnswers: Map<String, String> = emptyMap()
    private var initialVisibleIndices: Set<Int> = emptySet()
    private var uiChangedSinceBaseline = false
    private var answerLoadJob: Job? = null
    private var inflationGeneration = 0
    private var questionsLoading = false
    private val pendingInflationQueue = mutableListOf<Int>()
    private val listenerInstalledIndices = mutableSetOf<Int>()

    override fun startQuestionnaire() {
        val (meta, questionsList) = loadQuestionnaireFromJson(questionnaireFileName)
        questionnaireMeta = meta
        questions = questionsList
        currentIndex = 0
        ViewUtils.setTextSizeScale(ViewUtils.EMBEDDED_TEXT_SCALE)

        val clientCode = GlobalValues.LOADED_CLIENT_CODE?.trim().orEmpty()
            .ifEmpty { GlobalValues.LAST_CLIENT_CODE?.trim().orEmpty() }
        answerLoadJob = CoroutineScope(Dispatchers.Main).launch {
            if (clientCode.isNotEmpty()) {
                withContext(Dispatchers.IO) {
                    SavedAnswerLoader.loadInto(
                        clientCode = clientCode,
                        questionnaireId = meta.id,
                        questions = questionsList,
                        answers = answers,
                    )
                }
                answers["client_code"] = clientCode
                TokenStore.getUsername(context)?.let { answers["coach_code"] = it }
            }
            persistedBaselineAnswers = snapshotAnswerKeys(answers)
        }
        buildAllInOneUi()
    }

    override fun showCurrentQuestion() {
        // Not used in all-in-one mode
    }

    private fun buildAllInOneUi() {
        sections.clear()
        setupComplete = false
        initialSetupComplete = false
        initialVisibleIndices = emptySet()
        uiRevealed = false
        questionsLoading = true
        pendingInflationQueue.clear()
        listenerInstalledIndices.clear()
        ViewUtils.setTextSizeScale(ViewUtils.EMBEDDED_TEXT_SCALE)

        context.setContentView(R.layout.questionnaire_all_in_one)
        val contentRoot = context.findViewById<android.view.ViewGroup>(android.R.id.content)?.getChildAt(0)
        com.tomhempel.rbarometer.ui.SessionUi.applyQuestionScreen(context, contentRoot)
        ViewUtils.suppressAutofillForSubtree(context.findViewById(android.R.id.content))

        container = context.findViewById(R.id.questionContainer)
        btnSave = context.findViewById(R.id.btnSave)
        btnBack = context.findViewById(R.id.btnBack)
        val progressBar = context.findViewById<ProgressBar>(R.id.progressBar)
        contentLoadingOverlay = context.findViewById(R.id.questionnaireSaveLoading)
        scrollContainer = context.findViewById(R.id.scrollContainer)
        ViewUtils.suppressAutofillForSubtree(container)

        btnSave.text = LanguageManager.getText(languageID, "save")
        ViewUtils.setTextSizePercentOfScreenHeight(btnSave, 0.025f)

        btnSave.isEnabled = false
        btnSave.alpha = 0.5f

        btnBack.setOnClickListener {
            (context as? MainActivity)?.finishQuestionnaire()
        }

        setupLanguageSpinner(context.findViewById(R.id.langSpinner))

        ScreenLoading.setVisible(contentLoadingOverlay, true, scrollContainer, btnSave, btnBack)

        container.post {
            CoroutineScope(Dispatchers.Main).launch {
                answerLoadJob?.join()
                if (!isQuestionnaireActive()) return@launch
                beginStaggeredInflation()
            }
        }

        btnSave.setOnClickListener {
            handleSave(progressBar, contentLoadingOverlay, scrollContainer)
        }
    }

    private fun isQuestionnaireActive(): Boolean =
        context.findViewById<View>(R.id.questionContainer) != null

    private fun beginStaggeredInflation() {
        val generation = ++inflationGeneration
        questionsLoading = true
        updateSaveButtonState()
        sections.clear()
        container.removeAllViews()
        pendingInflationQueue.clear()
        pendingInflationQueue.addAll(initialQuestionIndicesToInflate())
        inflateQuestionSectionsBatch(generation)
    }

    private fun initialQuestionIndicesToInflate(): List<Int> =
        QuestionFlowResolver.visibleQuestionIndices(questions, ::branchAnswerFromAnswers).sorted()

    private fun inflateQuestionSectionsBatch(generation: Int) {
        if (generation != inflationGeneration || !isQuestionnaireActive()) return

        var inflatedThisFrame = 0

        while (pendingInflationQueue.isNotEmpty() && inflatedThisFrame < sectionsPerFrame()) {
            val idx = pendingInflationQueue.removeAt(0)
            inflateSingleSection(idx)
            inflatedThisFrame++
        }

        if (!uiRevealed && sections.isNotEmpty()) {
            revealQuestionnaireUi()
        }

        if (pendingInflationQueue.isNotEmpty()) {
            container.post { inflateQuestionSectionsBatch(generation) }
        } else if (initialSetupComplete) {
            finishDynamicInflation(generation)
        } else {
            finishInitialInflationSetup(generation)
        }
    }

    private fun sectionsPerFrame(): Int = if (uiRevealed) 3 else 1

    private fun inflateSingleSection(idx: Int): Section? {
        if (sections.any { it.index == idx }) {
            return sections.find { it.index == idx }
        }

        val question = questions.getOrNull(idx) ?: return null
        if (question is QuestionItem.LastPage) return null

        val layoutResId = getLayoutResId(question.layout ?: "default_layout")
        if (layoutResId == 0) return null

        val inflater = LayoutInflater.from(context)
        val sectionView = inflater.inflate(layoutResId, container, false)
        adaptLayoutForEmbedding(sectionView)

        val card = wrapInCard(sectionView)
        val insertAt = sections.indexOfFirst { it.index > idx }.let { if (it == -1) sections.size else it }
        container.addView(card, insertAt)

        val handler = QuestionHandlerFactory.create(
            question,
            NavigationMode.Embedded,
            handlerDependencies().copy(
                onAnswerChanged = {
                    notifyAnswerChanged(question)
                },
            ),
        )
        suppressSpinnerEvents = true
        try {
            handler?.bind(sectionView, question)
        } finally {
            suppressSpinnerEvents = false
        }

        val section = Section(idx, question, card, sectionView, handler)
        sections.add(insertAt, section)

        if (setupComplete) {
            installChangeListenersForSection(section)
        }

        return section
    }

    private fun revealQuestionnaireUi() {
        uiRevealed = true
        ScreenLoading.setVisible(contentLoadingOverlay, false, scrollContainer, btnSave, btnBack)
    }

    private fun finishInitialInflationSetup(generation: Int) {
        if (generation != inflationGeneration || !isQuestionnaireActive()) return
        installChangeListeners()
        recalculateVisibility()
        setupComplete = true
        if (pendingInflationQueue.isNotEmpty()) {
            container.post { inflateQuestionSectionsBatch(generation) }
            return
        }
        completeInitialSetup(generation)
    }

    private fun completeInitialSetup(generation: Int) {
        container.post {
            if (generation != inflationGeneration || !isQuestionnaireActive()) return@post
            if (!uiRevealed) revealQuestionnaireUi()
            initialVisibleIndices = currentVisibleIndices()
            uiChangedSinceBaseline = false
            initialSetupComplete = true
            questionsLoading = false
            updateSaveButtonState()
        }
    }

    private fun finishDynamicInflation(generation: Int) {
        if (generation != inflationGeneration || !isQuestionnaireActive()) return
        installChangeListeners()
        recalculateVisibility()
        container.post {
            if (generation != inflationGeneration || !isQuestionnaireActive()) return@post
            questionsLoading = false
            updateSaveButtonState()
        }
    }

    private fun currentVisibleIndices(): Set<Int> =
        sections.filter { it.card.visibility == View.VISIBLE }.map { it.index }.toSet()

    private fun setupLanguageSpinner(spinner: Spinner) {
        QuestionnaireLanguagePicker.wireSpinner(context, spinner, languageID) { newLang ->
            onLanguageChanged(newLang)
        }
    }

    override fun onLanguageChanged(newLang: String) {
        for (section in sections) {
            if (section.card.visibility == View.VISIBLE) {
                try { section.handler?.saveAnswer() } catch (_: Exception) {}
            }
        }
        languageID = newLang
        com.tomhempel.rbarometer.ui.AppLanguageStore.set(context, newLang)
        persistedBaselineAnswers = snapshotAnswerKeys(answers)
        uiChangedSinceBaseline = false
        buildAllInOneUi()
    }

    private fun adaptLayoutForEmbedding(view: View) {
        if (view is ConstraintLayout) {
            view.layoutParams = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT
            )
            // Ensure the last field in the card is never clipped at the bottom
            view.setPadding(
                view.paddingLeft, view.paddingTop,
                view.paddingRight, ViewUtils.dp(context, 12)
            )
        }

        view.findViewById<View>(R.id.Qprev)?.visibility = View.GONE
        view.findViewById<View>(R.id.Qnext)?.visibility = View.GONE

        view.findViewById<View>(R.id.gTop)?.let { guideline ->
            if (guideline is androidx.constraintlayout.widget.Guideline) {
                val params = guideline.layoutParams as? ConstraintLayout.LayoutParams
                params?.guideBegin = ViewUtils.dp(context, 8)
                guideline.layoutParams = params
            }
        }

        adaptInnerScrollView(view, R.id.radioScroll)
        adaptInnerScrollView(view, R.id.scrollView)
        adaptInnerScrollView(view, R.id.glassScroll)

        view.findViewById<EditText>(R.id.client_code)?.let { et ->
            ViewUtils.styleFormEditText(et)
            val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let
            if (params.matchConstraintPercentHeight > 0f) {
                params.matchConstraintPercentHeight = 0f
                params.height = ViewUtils.dp(context, 64)
                et.layoutParams = params
            }
        }
        view.findViewById<EditText>(R.id.coach_code)?.let { et ->
            ViewUtils.styleFormEditText(et)
            val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let
            if (params.matchConstraintPercentHeight > 0f) {
                params.matchConstraintPercentHeight = 0f
                params.height = ViewUtils.dp(context, 64)
                et.layoutParams = params
            }
        }
        view.findViewById<EditText>(R.id.free_text_input)?.let { et ->
            ViewUtils.styleFormEditText(et)
            val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let
            if (params.matchConstraintPercentHeight > 0f) {
                params.matchConstraintPercentHeight = 0f
                params.height = ViewUtils.dp(context, 120)
                et.layoutParams = params
            }
        }
    }

    private fun adaptInnerScrollView(root: View, scrollViewId: Int) {
        val sv = root.findViewById<ScrollView>(scrollViewId) ?: return
        val params = sv.layoutParams
        if (params is ConstraintLayout.LayoutParams) {
            params.height = ConstraintLayout.LayoutParams.WRAP_CONTENT
            params.bottomToTop = ConstraintLayout.LayoutParams.UNSET
            params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
            sv.layoutParams = params
        }
        sv.isNestedScrollingEnabled = false
    }

    private fun wrapInCard(sectionView: View): MaterialCardView {
        val card = MaterialCardView(context).apply {
            layoutParams = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT
            ).apply {
                val margin = ViewUtils.dp(context, 6)
                setMargins(0, margin, 0, margin)
            }
            radius = ViewUtils.dp(context, 16).toFloat()
            cardElevation = ViewUtils.dp(context, 2).toFloat()
            setCardBackgroundColor(ContextCompat.getColor(context, R.color.brand_surface))
            strokeColor = ContextCompat.getColor(context, R.color.brand_stroke)
            strokeWidth = ViewUtils.dp(context, 1)
            setContentPadding(
                ViewUtils.dp(context, 4),
                ViewUtils.dp(context, 8),
                ViewUtils.dp(context, 4),
                ViewUtils.dp(context, 12)
            )
        }
        card.addView(sectionView)
        return card
    }

    // ---- Visibility walk algorithm ----

    private fun recalculateVisibility() {
        val visibleIndices = QuestionFlowResolver.visibleQuestionIndices(questions, ::branchAnswerForFlow)

        val missing = visibleIndices.filter { idx -> sections.none { it.index == idx } }.sorted()
        if (missing.isNotEmpty()) {
            if (setupComplete) {
                questionsLoading = true
                updateSaveButtonState()
                for (idx in missing) {
                    if (idx !in pendingInflationQueue) pendingInflationQueue.add(idx)
                }
                pendingInflationQueue.sort()
                container.post { inflateQuestionSectionsBatch(inflationGeneration) }
            } else {
                missing.forEach { inflateSingleSection(it) }
            }
        }

        for (section in sections) {
            val shouldShow = visibleIndices.contains(section.index)
            section.card.visibility = if (shouldShow) View.VISIBLE else View.GONE
        }
    }

    private fun branchAnswerForFlow(q: QuestionItem): QuestionFlowResolver.BranchAnswer? {
        val section = sections.find { it.question === q }
        if (section != null) {
            if (!QuestionFlowResolver.hasBranchingOptions(q)) {
                return QuestionFlowResolver.BranchAnswer(languageID)
            }
            if (hasAnswerInUi(q, section)) {
                return branchAnswerFromUi(q, section)
            }
        }
        return branchAnswerFromAnswers(q)
    }

    private fun branchAnswerFromAnswers(q: QuestionItem): QuestionFlowResolver.BranchAnswer? {
        if (QuestionFlowResolver.hasBranchingOptions(q) && !hasAnswerInMap(q)) return null
        return when (q) {
            is QuestionItem.RadioQuestion -> {
                val key = answerString(q.question, q.id) ?: return null
                QuestionFlowResolver.BranchAnswer(languageID, selectedKey = key)
            }
            is QuestionItem.ValueSpinnerQuestion -> {
                val saved = AnswerLookup.valueSpinnerValue(answers, q) ?: return null
                if (SpinnerAnswerRules.isUnselected(languageID, saved)) return null
                QuestionFlowResolver.BranchAnswer(
                    languageID,
                    selectedInt = saved.toIntOrNull(),
                )
            }
            is QuestionItem.StringSpinnerQuestion -> {
                val saved = AnswerLookup.stringValue(answers, q) ?: return null
                if (SpinnerAnswerRules.isUnselected(languageID, saved)) return null
                QuestionFlowResolver.BranchAnswer(languageID, selectedDisplay = saved)
            }
            is QuestionItem.MultiCheckboxQuestion -> {
                val keys = selectedKeysFromAnswers(q) ?: return null
                QuestionFlowResolver.BranchAnswer(languageID, selectedKeys = keys)
            }
            else -> QuestionFlowResolver.BranchAnswer(languageID)
        }
    }

    private fun selectedKeysFromAnswers(q: QuestionItem.MultiCheckboxQuestion): Set<String>? {
        val key = q.question?.takeIf { it.isNotBlank() } ?: q.id.takeIf { it.isNotBlank() } ?: return null
        val keys = when (val stored = answers[key]) {
            is Collection<*> -> stored.mapNotNull { it?.toString() }.filter { it.isNotBlank() }.toSet()
            is String -> if (stored.isNotBlank()) parseMultiCheckboxAnswer(stored) else emptySet()
            else -> emptySet()
        }
        return keys.takeIf { it.size >= q.minSelection }
    }

    private fun parseMultiCheckboxAnswer(raw: String): Set<String> {
        val trimmed = raw.trim()
        if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
            val inner = trimmed.substring(1, trimmed.length - 1)
            if (inner.isBlank()) return emptySet()
            return inner.split(",")
                .map { it.trim().trim('"', '\'') }
                .filter { it.isNotEmpty() }
                .toSet()
        }
        val separator = when {
            trimmed.contains(",") -> ","
            trimmed.contains(";") -> ";"
            else -> null
        }
        return if (separator != null) {
            trimmed.split(separator)
                .map { it.trim().trim('"', '\'') }
                .filter { it.isNotEmpty() }
                .toSet()
        } else {
            setOf(trimmed.trim().trim('"', '\''))
        }
    }

    private fun answerString(vararg keys: String?): String? {
        for (key in keys) {
            if (key.isNullOrBlank()) continue
            val text = answers[key]?.toString()?.trim()
            if (!text.isNullOrEmpty()) return text
        }
        return null
    }

    private fun hasAnswerInMap(q: QuestionItem): Boolean = when (q) {
        is QuestionItem.RadioQuestion -> answerString(q.question, q.id) != null
        is QuestionItem.ValueSpinnerQuestion -> {
            val saved = AnswerLookup.valueSpinnerValue(answers, q)
            !SpinnerAnswerRules.isUnselected(languageID, saved)
        }
        is QuestionItem.StringSpinnerQuestion -> {
            val saved = AnswerLookup.stringValue(answers, q)
            !SpinnerAnswerRules.isUnselected(languageID, saved)
        }
        is QuestionItem.FreeTextQuestion -> {
            val key = q.question ?: q.id ?: return false
            TextInputSanitizer.isUsableAfterSanitize(
                answers[key]?.toString().orEmpty(),
                q.maxLength.coerceIn(1, 10_000),
            )
        }
        is QuestionItem.DateSpinnerQuestion -> {
            val key = q.question ?: q.id ?: return false
            DateAnswerFormat.parseStored(answers[key]?.toString()) != null
        }
        is QuestionItem.SliderQuestion -> answerString(q.question, q.id) != null
        is QuestionItem.MultiCheckboxQuestion -> selectedKeysFromAnswers(q) != null
        is QuestionItem.GlassScaleQuestion ->
            q.symptoms.isNotEmpty() && q.symptoms.all { answerString(it) != null }
        is QuestionItem.ClientCoachCodeQuestion ->
            answerString("client_code") != null && answerString("coach_code") != null
        else -> true
    }

    private fun branchAnswerFromUi(q: QuestionItem, section: Section): QuestionFlowResolver.BranchAnswer? {
        if (QuestionFlowResolver.hasBranchingOptions(q) && !hasAnswerInUi(q, section)) return null
        val view = section.sectionView
        return when (q) {
            is QuestionItem.RadioQuestion -> {
                val radioGroup = view.findViewById<RadioGroup>(R.id.RadioGroup) ?: return null
                val checkedId = radioGroup.checkedRadioButtonId
                if (checkedId == -1) return null
                val key = radioGroup.findViewById<RadioButton>(checkedId)?.tag?.toString() ?: return null
                QuestionFlowResolver.BranchAnswer(languageID, selectedKey = key)
            }
            is QuestionItem.ValueSpinnerQuestion -> {
                val spinner = view.findViewById<Spinner>(R.id.value_spinner) ?: return null
                val selected = spinner.selectedItem?.toString() ?: return null
                if (SpinnerAnswerRules.isUnselected(languageID, selected)) return null
                QuestionFlowResolver.BranchAnswer(
                    languageID,
                    selectedInt = selected.toIntOrNull(),
                )
            }
            is QuestionItem.StringSpinnerQuestion -> {
                val spinner = view.findViewById<Spinner>(R.id.string_spinner) ?: return null
                val selected = spinner.selectedItem?.toString() ?: return null
                if (SpinnerAnswerRules.isUnselected(languageID, selected)) return null
                QuestionFlowResolver.BranchAnswer(languageID, selectedDisplay = selected)
            }
            is QuestionItem.MultiCheckboxQuestion -> {
                val keys = selectedKeysFromUi(view, q) ?: return null
                QuestionFlowResolver.BranchAnswer(languageID, selectedKeys = keys)
            }
            else -> QuestionFlowResolver.BranchAnswer(languageID)
        }
    }

    private fun selectedKeysFromUi(view: View, q: QuestionItem.MultiCheckboxQuestion): Set<String>? {
        val container = view.findViewById<LinearLayout>(R.id.CheckboxContainer) ?: return null
        val keys = mutableSetOf<String>()
        for (i in 0 until container.childCount) {
            val cb = container.getChildAt(i) as? CheckBox ?: continue
            if (cb.isChecked) keys += cb.tag.toString()
        }
        return keys.takeIf { it.size >= q.minSelection }
    }

    private fun hasAnswerInUi(q: QuestionItem, section: Section): Boolean {
        val view = section.sectionView

        return when (q) {
            is QuestionItem.RadioQuestion -> {
                val rg = view.findViewById<RadioGroup>(R.id.RadioGroup) ?: return false
                rg.checkedRadioButtonId != -1
            }
            is QuestionItem.ValueSpinnerQuestion -> {
                val spinner = view.findViewById<Spinner>(R.id.value_spinner) ?: return false
                !SpinnerAnswerRules.isUnselected(languageID, spinner.selectedItem?.toString())
            }
            is QuestionItem.StringSpinnerQuestion -> {
                val spinner = view.findViewById<Spinner>(R.id.string_spinner) ?: return false
                !SpinnerAnswerRules.isUnselected(languageID, spinner.selectedItem?.toString())
            }
            is QuestionItem.FreeTextQuestion -> {
                val field = view.findViewById<android.widget.EditText>(R.id.free_text_input) ?: return false
                val maxLen = q.maxLength.coerceIn(1, 10_000)
                TextInputSanitizer.isUsableAfterSanitize(
                    field.text?.toString().orEmpty(),
                    maxLen,
                )
            }
            is QuestionItem.DateSpinnerQuestion ->
                isDateSectionValidSilently(view, q, emptyMap())
            is QuestionItem.SliderQuestion -> true
            is QuestionItem.MultiCheckboxQuestion -> selectedKeysFromUi(view, q) != null
            is QuestionItem.GlassScaleQuestion -> {
                val table = view.findViewById<TableLayout>(R.id.glass_table) ?: return false
                for (i in 0 until table.childCount) {
                    val row = table.getChildAt(i) as? TableRow ?: return false
                    val radioGroup = row.getChildAt(1) as? RadioGroup ?: return false
                    var anyChecked = false
                    for (j in 0 until radioGroup.childCount) {
                        val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
                        if (rb.isChecked) {
                            anyChecked = true
                            break
                        }
                    }
                    if (!anyChecked) return false
                }
                true
            }
            is QuestionItem.ClientCoachCodeQuestion -> {
                val client = view.findViewById<EditText>(R.id.client_code)?.text?.toString().orEmpty()
                val coach = view.findViewById<EditText>(R.id.coach_code)?.text?.toString().orEmpty()
                client.isNotBlank() && coach.isNotBlank()
            }
            else -> true
        }
    }

    // ---- Change listeners ----

    private fun installChangeListeners() {
        for (section in sections) {
            installChangeListenersForSection(section)
        }
    }

    private fun installChangeListenersForSection(section: Section) {
        if (!listenerInstalledIndices.add(section.index)) return
        val view = section.sectionView

        when (section.question) {
                is QuestionItem.RadioQuestion -> {
                    view.findViewById<RadioGroup>(R.id.RadioGroup)
                        ?.setOnCheckedChangeListener { _, checkedId ->
                            if (!setupComplete || checkedId == -1) return@setOnCheckedChangeListener
                            notifyAnswerChanged(section.question)
                        }
                }
                is QuestionItem.ValueSpinnerQuestion -> {
                    installSpinnerChangeListener(view, R.id.value_spinner, section.question)
                }
                // Slider changes are reported by HandlerSliderQuestion via HandlerDependencies.onAnswerChanged.
                is QuestionItem.StringSpinnerQuestion -> {
                    installSpinnerChangeListener(view, R.id.string_spinner, section.question)
                }
                is QuestionItem.DateSpinnerQuestion -> {
                    val listener = createBranchingSpinnerListener(section.question)
                    view.findViewById<Spinner>(R.id.spinner_value_day)?.onItemSelectedListener = listener
                    view.findViewById<Spinner>(R.id.spinner_value_month)?.onItemSelectedListener = listener
                    view.findViewById<Spinner>(R.id.spinner_value_year)?.onItemSelectedListener = listener
                }
                is QuestionItem.GlassScaleQuestion -> {
                    installGlassScaleClickListeners(view, section.question)
                }
                is QuestionItem.MultiCheckboxQuestion -> {
                    installCheckboxClickListeners(view, section.question)
                }
                is QuestionItem.ClientCoachCodeQuestion -> {
                    installEditTextWatcher(view, R.id.client_code, section.question)
                    installEditTextWatcher(view, R.id.coach_code, section.question)
                }
                is QuestionItem.FreeTextQuestion -> {
                    installEditTextWatcher(view, R.id.free_text_input, section.question)
                }
                else -> {}
        }
    }

    private fun installSpinnerChangeListener(view: View, spinnerId: Int, question: QuestionItem) {
        view.findViewById<Spinner>(spinnerId)?.onItemSelectedListener =
            object : AdapterView.OnItemSelectedListener {
                override fun onItemSelected(
                    parent: AdapterView<*>?, v: View?, pos: Int, id: Long
                ) {
                    if (!setupComplete || suppressSpinnerEvents) return
                    notifyAnswerChanged(question)
                }
                override fun onNothingSelected(parent: AdapterView<*>?) {}
            }
    }

    private fun createBranchingSpinnerListener(question: QuestionItem): AdapterView.OnItemSelectedListener {
        return object : AdapterView.OnItemSelectedListener {
            override fun onItemSelected(parent: AdapterView<*>?, v: View?, pos: Int, id: Long) {
                if (!setupComplete || suppressSpinnerEvents) return
                notifyAnswerChanged(question)
            }
            override fun onNothingSelected(parent: AdapterView<*>?) {}
        }
    }

    private fun installGlassScaleClickListeners(view: View, question: QuestionItem) {
        val table = view.findViewById<TableLayout>(R.id.glass_table) ?: return
        for (r in 0 until table.childCount) {
            val row = table.getChildAt(r) as? TableRow ?: continue
            val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue
            for (c in 0 until radioGroup.childCount) {
                val rb = getRadioFromChild(radioGroup.getChildAt(c)) ?: continue
                rb.setOnClickListener {
                    if (setupComplete) notifyAnswerChanged(question)
                }
            }
        }
    }

    private fun getRadioFromChild(child: View): RadioButton? = when (child) {
        is RadioButton -> child
        is FrameLayout -> child.getChildAt(0) as? RadioButton
        else -> null
    }

    private fun installCheckboxClickListeners(view: View, question: QuestionItem) {
        val cont = view.findViewById<LinearLayout>(R.id.CheckboxContainer) ?: return
        for (i in 0 until cont.childCount) {
            val cb = cont.getChildAt(i) as? CheckBox ?: continue
            cb.setOnClickListener {
                if (setupComplete) notifyAnswerChanged(question)
            }
        }
    }

    private fun installEditTextWatcher(view: View, editTextId: Int, question: QuestionItem) {
        view.findViewById<EditText>(editTextId)?.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
            override fun afterTextChanged(s: Editable?) {
                if (!setupComplete) return
                notifyAnswerChanged(question)
            }
        })
    }

    private fun notifyAnswerChanged(question: QuestionItem) {
        if (!setupComplete) return
        if (QuestionFlowResolver.hasBranchingOptions(question)) {
            recalculateVisibility()
        }
        markUiChanged()
    }

    private fun markUiChanged() {
        if (!setupComplete) return
        uiChangedSinceBaseline = true
        updateSaveButtonState()
    }

    // ---- Save button state ----

    private fun answerKeysFor(question: QuestionItem): List<String> = when (question) {
        is QuestionItem.StringSpinnerQuestion -> listOf(question.question.ifBlank { question.id })
        is QuestionItem.ValueSpinnerQuestion -> listOf(question.question.ifBlank { question.id })
        is QuestionItem.RadioQuestion -> listOf(question.question ?: question.id)
        is QuestionItem.FreeTextQuestion -> listOf(question.question.ifBlank { question.id })
        is QuestionItem.DateSpinnerQuestion -> listOf(question.question.ifBlank { question.id })
        is QuestionItem.SliderQuestion -> listOf(question.question.ifBlank { question.id })
        is QuestionItem.MultiCheckboxQuestion -> listOf(question.question.ifBlank { question.id })
        is QuestionItem.GlassScaleQuestion -> question.symptoms
        is QuestionItem.ClientCoachCodeQuestion -> listOf("client_code", "coach_code")
        else -> emptyList()
    }

    private fun removableAnswerKeysFor(question: QuestionItem): Set<String> =
        (answerKeysFor(question) + question.id).filter { it.isNotBlank() }.toSet()

    private fun snapshotAnswerKeys(source: Map<String, Any>): Map<String, String> {
        val snap = sortedMapOf<String, String>()
        for (q in questions) {
            for (key in answerKeysFor(q)) {
                if (key.isBlank()) continue
                val raw = source[key] ?: continue
                snap[key] = canonicalAnswerValue(raw)
            }
        }
        return snap
    }

    private fun canonicalAnswerValue(value: Any?): String = when (value) {
        null -> ""
        is List<*> -> value.mapNotNull { it?.toString()?.trim() }.filter { it.isNotEmpty() }.sorted()
            .joinToString("|")
        else -> value.toString().trim()
    }

    private fun snapshotVisibleUiAnswers(): Map<String, String> {
        val snap = sortedMapOf<String, String>()
        for (section in sections.filter { it.card.visibility == View.VISIBLE }) {
            addUiAnswerToSnapshot(snap, section.question, section.sectionView)
        }
        return snap
    }

    private fun addUiAnswerToSnapshot(
        snap: MutableMap<String, String>,
        question: QuestionItem,
        view: View,
    ) {
        when (question) {
            is QuestionItem.RadioQuestion -> {
                val key = question.question ?: question.id
                val radioGroup = view.findViewById<RadioGroup>(R.id.RadioGroup) ?: return
                val checkedId = radioGroup.checkedRadioButtonId
                if (key.isNotBlank() && checkedId != -1) {
                    radioGroup.findViewById<RadioButton>(checkedId)?.tag?.toString()?.let {
                        snap[key] = canonicalAnswerValue(it)
                    }
                }
            }
            is QuestionItem.StringSpinnerQuestion -> {
                val key = question.question.ifBlank { question.id }
                val selected = view.findViewById<Spinner>(R.id.string_spinner)?.selectedItem?.toString()
                if (key.isNotBlank() && !SpinnerAnswerRules.isUnselected(languageID, selected)) {
                    snap[key] = canonicalAnswerValue(selected)
                }
            }
            is QuestionItem.ValueSpinnerQuestion -> {
                val key = question.question.ifBlank { question.id }
                val selected = view.findViewById<Spinner>(R.id.value_spinner)?.selectedItem?.toString()
                if (key.isNotBlank() && !SpinnerAnswerRules.isUnselected(languageID, selected)) {
                    snap[key] = canonicalAnswerValue(selected)
                }
            }
            is QuestionItem.DateSpinnerQuestion -> {
                val key = question.question.ifBlank { question.id }
                selectedDateValue(view, question)?.let { if (key.isNotBlank()) snap[key] = it }
            }
            is QuestionItem.SliderQuestion -> {
                val key = question.question.ifBlank { question.id }
                val seekBar = view.findViewById<SeekBar>(R.id.value_slider) ?: return
                val min = question.range?.min ?: 0
                val step = (question.step ?: 1).coerceAtLeast(1)
                if (key.isNotBlank()) snap[key] = (min + seekBar.progress * step).toString()
            }
            is QuestionItem.MultiCheckboxQuestion -> {
                val key = question.question.ifBlank { question.id }
                val container = view.findViewById<LinearLayout>(R.id.CheckboxContainer) ?: return
                val selected = mutableListOf<String>()
                for (i in 0 until container.childCount) {
                    val cb = container.getChildAt(i) as? CheckBox ?: continue
                    if (cb.isChecked) selected += cb.tag.toString()
                }
                if (key.isNotBlank() && selected.isNotEmpty()) {
                    snap[key] = canonicalAnswerValue(selected)
                }
            }
            is QuestionItem.GlassScaleQuestion -> {
                val table = view.findViewById<TableLayout>(R.id.glass_table) ?: return
                for (i in 0 until table.childCount) {
                    val symptomKey = question.symptoms.getOrNull(i)?.takeIf { it.isNotBlank() } ?: continue
                    val row = table.getChildAt(i) as? TableRow ?: continue
                    val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue
                    for (j in 0 until radioGroup.childCount) {
                        val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
                        if (rb.isChecked) {
                            snap[symptomKey] = canonicalAnswerValue(rb.tag)
                            break
                        }
                    }
                }
            }
            is QuestionItem.FreeTextQuestion -> {
                val key = question.question.ifBlank { question.id }
                val raw = view.findViewById<EditText>(R.id.free_text_input)?.text?.toString().orEmpty()
                val maxLen = question.maxLength.coerceIn(1, 10_000)
                val clean = TextInputSanitizer.sanitize(raw, maxLen)
                if (key.isNotBlank() && clean.isNotBlank()) snap[key] = canonicalAnswerValue(clean)
            }
            is QuestionItem.ClientCoachCodeQuestion -> {
                view.findViewById<EditText>(R.id.client_code)?.text?.toString()?.takeIf { it.isNotBlank() }?.let {
                    snap["client_code"] = canonicalAnswerValue(it)
                }
                view.findViewById<EditText>(R.id.coach_code)?.text?.toString()?.takeIf { it.isNotBlank() }?.let {
                    snap["coach_code"] = canonicalAnswerValue(TokenStore.getUsername(context) ?: it)
                }
            }
            else -> Unit
        }
    }

    private fun selectedDateValue(view: View, question: QuestionItem.DateSpinnerQuestion): String? {
        val precision = DatePrecision.fromWire(question.precision)
        val key = question.question.ifBlank { question.id }
        val year = view.findViewById<Spinner>(R.id.spinner_value_year)
            ?.selectedItem
            ?.toString()
            ?.toIntOrNull()
            ?: return null
        val month = if (precision == DatePrecision.YEAR) {
            1
        } else {
            selectedMonthNumber(view) ?: return null
        }
        val day = if (precision == DatePrecision.FULL) {
            view.findViewById<Spinner>(R.id.spinner_value_day)
                ?.selectedItem
                ?.toString()
                ?.toIntOrNull()
                ?: return null
        } else {
            1
        }
        val current = DateAnswerFormat.format(precision, year, month, day)
        val stored = answers[key] as? String
        if (storedSelectionMatchesUi(stored, year, month, day)) {
            return stored?.trim()
        }
        return current
    }

    private fun selectedMonthNumber(view: View): Int? {
        val month = view.findViewById<Spinner>(R.id.spinner_value_month)?.selectedItem
        val months = com.tomhempel.rbarometer.ui.Months.getAllMonths(languageID)
        val idx = months.indexOf(month)
        return if (idx >= 0) idx + 1 else null
    }

    private fun storedSelectionMatchesUi(stored: String?, year: Int, month: Int, day: Int): Boolean {
        val parsed = DateAnswerFormat.parseStored(stored) ?: return false
        return parsed.year == year && parsed.month == month && parsed.day == day
    }

    private fun isSectionValidSilently(section: Section, uiSnapshot: Map<String, String>): Boolean {
        val view = section.sectionView
        return when (val question = section.question) {
            is QuestionItem.RadioQuestion ->
                view.findViewById<RadioGroup>(R.id.RadioGroup)?.checkedRadioButtonId != -1

            is QuestionItem.StringSpinnerQuestion -> {
                val selected = view.findViewById<Spinner>(R.id.string_spinner)?.selectedItem?.toString()
                !SpinnerAnswerRules.isUnselected(languageID, selected)
            }

            is QuestionItem.ValueSpinnerQuestion -> {
                val selected = view.findViewById<Spinner>(R.id.value_spinner)?.selectedItem?.toString()
                !SpinnerAnswerRules.isUnselected(languageID, selected)
            }

            is QuestionItem.DateSpinnerQuestion ->
                isDateSectionValidSilently(view, question, uiSnapshot)

            is QuestionItem.SliderQuestion -> true

            is QuestionItem.MultiCheckboxQuestion -> {
                val container = view.findViewById<LinearLayout>(R.id.CheckboxContainer) ?: return false
                var selectedCount = 0
                for (i in 0 until container.childCount) {
                    val cb = container.getChildAt(i) as? CheckBox ?: continue
                    if (cb.isChecked) selectedCount++
                }
                selectedCount >= question.minSelection
            }

            is QuestionItem.GlassScaleQuestion -> {
                val table = view.findViewById<TableLayout>(R.id.glass_table) ?: return false
                for (i in 0 until table.childCount) {
                    val row = table.getChildAt(i) as? TableRow ?: return false
                    val radioGroup = row.getChildAt(1) as? RadioGroup ?: return false
                    var anyChecked = false
                    for (j in 0 until radioGroup.childCount) {
                        val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
                        if (rb.isChecked) {
                            anyChecked = true
                            break
                        }
                    }
                    if (!anyChecked) return false
                }
                true
            }

            is QuestionItem.FreeTextQuestion -> {
                val raw = view.findViewById<EditText>(R.id.free_text_input)?.text?.toString().orEmpty()
                val maxLen = question.maxLength.coerceIn(1, 10_000)
                TextInputSanitizer.isUsableAfterSanitize(raw, maxLen)
            }

            is QuestionItem.ClientCoachCodeQuestion -> {
                val clientCode = view.findViewById<EditText>(R.id.client_code)?.text
                val coachCode = view.findViewById<EditText>(R.id.coach_code)?.text
                !clientCode.isNullOrBlank() && !coachCode.isNullOrBlank()
            }

            else -> true
        }
    }

    private fun isDateSectionValidSilently(
        view: View,
        question: QuestionItem.DateSpinnerQuestion,
        uiSnapshot: Map<String, String>,
    ): Boolean {
        val selected = selectedDateValue(view, question) ?: return false
        if (DatePrecision.fromWire(question.precision) == DatePrecision.FULL && !isValidFullDate(view)) {
            return false
        }

        return true
    }

    private fun isValidFullDate(view: View): Boolean {
        val year = view.findViewById<Spinner>(R.id.spinner_value_year)
            ?.selectedItem
            ?.toString()
            ?.toIntOrNull()
            ?: return false
        val month = selectedMonthNumber(view) ?: return false
        val day = view.findViewById<Spinner>(R.id.spinner_value_day)
            ?.selectedItem
            ?.toString()
            ?.toIntOrNull()
            ?: return false
        val cal = Calendar.getInstance()
        cal.set(year, month - 1, 1)
        return day <= cal.getActualMaximum(Calendar.DAY_OF_MONTH)
    }

    private fun updateSaveButtonState() {
        if (!::btnSave.isInitialized || !setupComplete) {
            if (::btnSave.isInitialized) {
                btnSave.isEnabled = false
                btnSave.alpha = 0.5f
            }
            return
        }
        if (questionsLoading || pendingInflationQueue.isNotEmpty()) {
            btnSave.isEnabled = false
            btnSave.alpha = 0.5f
            return
        }
        val uiSnapshot = snapshotVisibleUiAnswers()
        val hasChanges = uiChangedSinceBaseline ||
            answersDifferFromPersisted(uiSnapshot) ||
            currentVisibleIndices() != initialVisibleIndices
        val allValid = sections
            .filter { it.card.visibility == View.VISIBLE }
            .all { isSectionValidSilently(it, uiSnapshot) }
        val enabled = hasChanges && allValid
        btnSave.isEnabled = enabled
        btnSave.alpha = if (enabled) 1f else 0.5f
    }

    private fun answersDifferFromPersisted(uiSnapshot: Map<String, String>): Boolean {
        for ((key, value) in uiSnapshot) {
            if (persistedBaselineAnswers[key] != value) return true
        }
        return false
    }

    // ---- Save flow ----

    private fun handleSave(progressBar: ProgressBar, saveOverlay: View, scrollContent: View) {
        val visibleSections = sections.filter { it.card.visibility == View.VISIBLE }
        val uiSnapshot = snapshotVisibleUiAnswers()

        for (section in visibleSections) {
            if (!isSectionValidSilently(section, uiSnapshot)) {
                showToast(LanguageManager.getText(languageID, "fill_all_fields"))
                scrollToSection(section)
                return
            }
        }

        pruneHiddenAnswers(visibleSections)

        for (section in visibleSections) {
            if (section.question is QuestionItem.ClientCoachCodeQuestion) {
                saveClientCoachCodeFromUi(section.sectionView)
            }
            section.handler?.saveAnswer()
        }

        val completedClientCode = (answers["client_code"] as? String)?.trim().orEmpty()
        showLoading(progressBar, saveOverlay, scrollContent, true)

        CoroutineScope(Dispatchers.IO).launch {
            QuestionnaireCompletionService.completeAndFinish(
                context = context,
                answers = answers,
                questionnaireId = questionnaireMeta.id,
                languageId = languageID,
                questions = questions,
                minLoadingMs = 1500L,
                onCompleted = {
                    openPostCompletionScoreReview(
                        clientCode = completedClientCode,
                        progressBar = progressBar,
                        saveOverlay = saveOverlay,
                        scrollContent = scrollContent,
                    )
                },
            )
        }
    }

    private fun openPostCompletionScoreReview(
        clientCode: String,
        progressBar: ProgressBar,
        saveOverlay: View,
        scrollContent: View,
    ) {
        val activity = context as? MainActivity
        val token = activity?.let { TokenStore.getToken(it) }
        val validToken = token?.takeIf { it.isNotBlank() }
        if (activity == null || clientCode.isBlank() || validToken == null || !TokenStore.isSessionValid(activity)) {
            showLoading(progressBar, saveOverlay, scrollContent, false)
            activity?.finishQuestionnaire(wasCompleted = true)
            return
        }

        CoroutineScope(Dispatchers.Main).launch {
            val hasScores = withContext(Dispatchers.IO) {
                ReviewScoresHandler.hasScoresForClient(activity, validToken, clientCode)
            }
            showLoading(progressBar, saveOverlay, scrollContent, false)
            if (!hasScores) {
                activity.finishQuestionnaire(wasCompleted = true)
                return@launch
            }

            ReviewScoresHandler(
                activity = activity,
                token = validToken,
                languageIDProvider = { languageID },
                clientCodeFilter = clientCode,
                onLanguageChanged = { newLang ->
                    languageID = newLang
                    AppLanguageStore.set(activity, newLang)
                },
                onClose = { activity.finishQuestionnaire(wasCompleted = true) },
            ).show()
        }
    }

    private fun pruneHiddenAnswers(visibleSections: List<Section>) {
        val visibleIndices = visibleSections.map { it.index }.toSet()
        for (section in sections) {
            if (section.index in visibleIndices) continue
            for (key in removableAnswerKeysFor(section.question)) {
                answers.remove(key)
            }
        }
    }

    private fun saveClientCoachCodeFromUi(view: View) {
        val clientCode = view.findViewById<EditText>(R.id.client_code)?.text?.toString() ?: ""
        val coachCode = view.findViewById<EditText>(R.id.coach_code)?.text?.toString() ?: ""
        GlobalValues.LAST_CLIENT_CODE = clientCode
        answers["client_code"] = clientCode
        answers["coach_code"] = TokenStore.getUsername(context) ?: coachCode
    }

    private fun showLoading(
        progressBar: ProgressBar,
        saveOverlay: View,
        scrollContent: View,
        show: Boolean,
    ) {
        progressBar.visibility = if (show) View.VISIBLE else View.GONE
        ScreenLoading.setVisible(saveOverlay, show, scrollContent)
        if (show) {
            btnSave.isEnabled = false
            btnSave.alpha = 0.5f
        } else {
            updateSaveButtonState()
        }
    }

    private fun scrollToSection(section: Section) {
        val scrollView = context.findViewById<androidx.core.widget.NestedScrollView>(R.id.scrollContainer)
        scrollView?.post {
            scrollView.smoothScrollTo(0, section.card.top)
        }
    }
}
