package eu.mhsl.marianum.mobile.client.widgets

import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
import eu.mhsl.marianum.mobile.client.MainActivity
import eu.mhsl.marianum.mobile.client.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.max

/**
 * Renders the day and week widgets as a time-grid: lesson blocks absolutely
 * positioned on a vertical axis, mirroring the in-app Syncfusion calendar.
 * Per-hour dp is computed from the widget bundle so the grid scales with
 * resize, clamped to [MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP].
 */
object WidgetRenderer {
    private const val FALLBACK_VIRTUAL_MINUTES = 11 * 60

    private const val DAY_CHROME_DP = 40
    private const val WEEK_CHROME_DP = 64

    private const val MIN_HOUR_HEIGHT_DP = 18
    private const val MAX_HOUR_HEIGHT_DP = 72

    private const val MIN_BLOCK_HEIGHT_DP = 16
    private const val LESSON_GAP_DP = 1.5f

    // Below SHOW_ROOM_MIN: subject only. Below SHOW_TEACHER_SEPARATE_MIN:
    // subject + room. Above: subject + room + teacher stacked.
    private const val BLOCK_SHOW_ROOM_MIN_DP = 18
    private const val BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP = 30

    /// Below this column width autoSize can't fit subject + room — drop
    /// room/teacher entirely on the week-widget.
    private const val WEEK_COLUMN_TIGHT_DP = 45

    private val timeFormat = SimpleDateFormat("HH:mm", Locale.GERMAN)
    private val dateShort = SimpleDateFormat("dd.MM.", Locale.GERMAN)
    private val weekdayShort = SimpleDateFormat("EE", Locale.GERMAN)
    private val dateTimeShort = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)

    /// Hex values mirror LightAppTheme / DarkAppTheme tokens so the widget
    /// matches the app's branding rather than the generic system look.
    private data class WidgetPalette(
        val background: Int,
        val textPrimary: Int,
        val textSecondary: Int,
        val divider: Int,
        val breakBlock: Int,
        val watermarkAlpha: Float,
    )

    private val lightPalette = WidgetPalette(
        background = 0xFFFCF7F5.toInt(),
        textPrimary = 0xFF1A1A1A.toInt(),
        textSecondary = 0xFF555555.toInt(),
        divider = 0x22000000,
        breakBlock = 0x0C000000,
        watermarkAlpha = 0.014f,
    )

    private val darkPalette = WidgetPalette(
        background = 0xFF1F1716.toInt(),
        textPrimary = 0xFFF1F1F1.toInt(),
        textSecondary = 0xFFB0B0B0.toInt(),
        divider = 0x33FFFFFF,
        breakBlock = 0x14FFFFFF,
        watermarkAlpha = 0.025f,
    )

    private fun resolvePalette(context: Context, themeMode: String?): WidgetPalette {
        val isDark = when (themeMode) {
            "light" -> false
            "dark" -> true
            else -> {
                val uiMode = context.resources.configuration.uiMode and
                    Configuration.UI_MODE_NIGHT_MASK
                uiMode == Configuration.UI_MODE_NIGHT_YES
            }
        }
        return if (isDark) darkPalette else lightPalette
    }

    fun buildDay(
        context: Context,
        packageName: String,
        options: Bundle? = null,
    ): RemoteViews {
        val prefs = sharedPrefs(context)
        val palette = resolvePalette(context, prefs.getString(KEY_THEME_MODE, "system"))
        if (!prefs.getBoolean(KEY_LOGGED_IN, false)) {
            return buildPlaceholder(
                context,
                packageName,
                context.getString(R.string.widget_login_required),
                palette,
            )
        }
        val data = WidgetDataParser.parse(prefs.getString(KEY_DAY_DATA, null))
            ?: return buildPlaceholder(
                context,
                packageName,
                context.getString(R.string.widget_loading),
                palette,
            )

        val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes
            ?: FALLBACK_VIRTUAL_MINUTES
        val hourHeightDp = resolveHourHeight(options, DAY_CHROME_DP, totalVirtualMin)

        val views = RemoteViews(packageName, R.layout.widget_day)
        applyChrome(views, palette, options)
        views.setTextColor(R.id.widget_day_title, palette.textPrimary)
        views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary)
        views.setTextColor(R.id.widget_day_empty, palette.textSecondary)
        views.setTextViewText(
            R.id.widget_day_title,
            "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}",
        )
        views.setTextViewText(
            R.id.widget_day_subtitle,
            context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)),
        )

        views.removeAllViews(R.id.widget_day_time_labels)
        views.removeAllViews(R.id.widget_day_grid)

        if (data.isHoliday) {
            views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE)
            views.setTextViewText(
                R.id.widget_day_empty,
                data.holidayName ?: context.getString(R.string.widget_holiday),
            )
        } else if (data.lessons.isEmpty()) {
            views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE)
            views.setTextViewText(
                R.id.widget_day_empty,
                context.getString(R.string.widget_no_lessons),
            )
        } else {
            views.setViewVisibility(R.id.widget_day_empty, View.GONE)
            populateGridLines(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods)
            populateTimeLabels(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods)
            populateGridLines(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods)
            populateBreakBlocks(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods)
            for (lesson in data.lessons) {
                addLessonBlock(
                    context = context,
                    packageName = packageName,
                    parent = views,
                    containerId = R.id.widget_day_grid,
                    lesson = lesson,
                    hourHeightDp = hourHeightDp,
                    periods = data.periods,
                    subjectOnly = false,
                    horizontalPaddingDp = 7,
                )
            }
        }

        views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
        return views
    }

    fun buildWeek(
        context: Context,
        packageName: String,
        options: Bundle? = null,
    ): RemoteViews {
        val prefs = sharedPrefs(context)
        val palette = resolvePalette(context, prefs.getString(KEY_THEME_MODE, "system"))
        if (!prefs.getBoolean(KEY_LOGGED_IN, false)) {
            return buildPlaceholder(
                context,
                packageName,
                context.getString(R.string.widget_login_required),
                palette,
            )
        }
        val data = WidgetDataParser.parse(prefs.getString(KEY_WEEK_DATA, null))
            ?: return buildPlaceholder(
                context,
                packageName,
                context.getString(R.string.widget_loading),
                palette,
            )

        val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes
            ?: FALLBACK_VIRTUAL_MINUTES
        val hourHeightDp = resolveHourHeight(options, WEEK_CHROME_DP, totalVirtualMin)

        val views = RemoteViews(packageName, R.layout.widget_week)
        applyChrome(views, palette, options)
        views.setTextColor(R.id.widget_week_title, palette.textPrimary)
        views.setTextColor(R.id.widget_week_subtitle, palette.textSecondary)
        val cal = Calendar.getInstance().apply { time = data.anchorDate }
        val weekNumber = cal.get(Calendar.WEEK_OF_YEAR)
        val end = Calendar.getInstance().apply {
            time = data.anchorDate
            add(Calendar.DAY_OF_YEAR, 4)
        }.time
        val kwPrefix = context.getString(R.string.widget_calendar_week_prefix)
        views.setTextViewText(
            R.id.widget_week_title,
            "$kwPrefix $weekNumber · ${dateShort.format(data.anchorDate)}–${dateShort.format(end)}",
        )
        views.setTextViewText(
            R.id.widget_week_subtitle,
            context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)),
        )

        val headerIds = listOf(
            R.id.widget_week_header_mon,
            R.id.widget_week_header_tue,
            R.id.widget_week_header_wed,
            R.id.widget_week_header_thu,
            R.id.widget_week_header_fri,
        )
        val columnIds = listOf(
            R.id.widget_week_col_mon,
            R.id.widget_week_col_tue,
            R.id.widget_week_col_wed,
            R.id.widget_week_col_thu,
            R.id.widget_week_col_fri,
        )
        views.removeAllViews(R.id.widget_week_time_labels)
        populateGridLines(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods)
        populateTimeLabels(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods)

        val (weekWidthDp, _) = widgetSizeDp(options)
        // Time-label column is 28dp wide; the rest is split across 5 days
        // plus thin dividers (negligible). Drop room/teacher only on the
        // very narrowest week widgets — autoSize handles the in-between
        // sizes.
        val dayColumnWidthDp = (weekWidthDp - 28f - 20f) / 5f
        val subjectOnly = dayColumnWidthDp < WEEK_COLUMN_TIGHT_DP

        for ((index, columnId) in columnIds.withIndex()) {
            views.removeAllViews(headerIds[index])
            views.removeAllViews(columnId)
            val day = Calendar.getInstance().apply {
                time = data.anchorDate
                add(Calendar.DAY_OF_YEAR, index)
            }.time
            val header = RemoteViews(packageName, R.layout.widget_week_day_header)
            header.setTextColor(R.id.widget_week_day_header_weekday, palette.textPrimary)
            header.setTextColor(R.id.widget_week_day_header_date, palette.textSecondary)
            header.setTextViewText(R.id.widget_week_day_header_weekday, weekdayShort.format(day))
            header.setTextViewText(R.id.widget_week_day_header_date, dateShort.format(day))
            views.addView(headerIds[index], header)

            populateGridLines(packageName, views, columnId, hourHeightDp, palette, data.periods)
            populateBreakBlocks(packageName, views, columnId, hourHeightDp, palette, data.periods)
            for (lesson in data.lessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) {
                addLessonBlock(
                    context = context,
                    packageName = packageName,
                    parent = views,
                    containerId = columnId,
                    lesson = lesson,
                    hourHeightDp = hourHeightDp,
                    periods = data.periods,
                    subjectOnly = subjectOnly,
                    horizontalPaddingDp = 3,
                )
            }
        }

        views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
        return views
    }

    /// Pulls the launcher-reported widget size out of the AppWidget options
    /// bundle. The grid now spans `totalVirtualMin` minutes (lessons +
    /// preserved big breaks), so we divide by that instead of a fixed hour
    /// count to keep tiles readable across different timetables.
    private fun resolveHourHeight(
        options: Bundle?,
        chromeDp: Int,
        totalVirtualMin: Int,
    ): Float {
        val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f)
        val rawHeightDp = options?.let {
            max(
                it.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0),
                it.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0),
            )
        } ?: 0
        if (rawHeightDp <= 0) return 32f
        val gridHeightDp = (rawHeightDp - chromeDp)
            .coerceAtLeast((MIN_HOUR_HEIGHT_DP * virtualHours).toInt())
        return (gridHeightDp.toFloat() / virtualHours)
            .coerceIn(MIN_HOUR_HEIGHT_DP.toFloat(), MAX_HOUR_HEIGHT_DP.toFloat())
    }

    /// Real wall-clock minute → position on the virtual axis. Inside a
    /// period: linear. In a gap: linear across the virtual gap (zero for
    /// squeezed small breaks, real width for big breaks).
    private fun realMinutesToVirtual(
        realMin: Int,
        periods: List<WidgetPeriod>,
    ): Float {
        if (periods.isEmpty()) return realMin.toFloat()
        for (period in periods) {
            if (realMin in period.startMinutes..period.endMinutes) {
                return period.virtualStartMinutes + (realMin - period.startMinutes).toFloat()
            }
        }
        val first = periods.first()
        if (realMin < first.startMinutes) {
            return (realMin - first.startMinutes + first.virtualStartMinutes).toFloat()
        }
        val last = periods.last()
        if (realMin > last.endMinutes) {
            return last.virtualEndMinutes + (realMin - last.endMinutes).toFloat()
        }
        var prev = first
        for (i in 1 until periods.size) {
            val curr = periods[i]
            if (realMin in (prev.endMinutes + 1) until curr.startMinutes) {
                val gap = curr.startMinutes - prev.endMinutes
                val virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
                return if (gap > 0) {
                    prev.virtualEndMinutes +
                        (realMin - prev.endMinutes).toFloat() * virtualGap / gap
                } else {
                    curr.virtualStartMinutes.toFloat()
                }
            }
            prev = curr
        }
        return 0f
    }

    /// Below this per-hour height the two-line label collapses to a single
    /// period number — time + number overlap otherwise.
    private const val TIME_LABEL_COMPACT_THRESHOLD_DP = 26f

    private fun populateTimeLabels(
        packageName: String,
        parent: RemoteViews,
        containerId: Int,
        hourHeightDp: Float,
        palette: WidgetPalette,
        periods: List<WidgetPeriod>,
    ) {
        val compact = hourHeightDp < TIME_LABEL_COMPACT_THRESHOLD_DP
        for (period in periods) {
            val label = RemoteViews(packageName, R.layout.widget_time_label)
            label.setTextViewText(R.id.widget_time_label_number, "${period.name}.")
            label.setTextViewText(R.id.widget_time_label_time, formatHm(period.startMinutes))
            if (compact) {
                label.setViewVisibility(R.id.widget_time_label_time, View.GONE)
                label.setTextViewTextSize(
                    R.id.widget_time_label_number,
                    TypedValue.COMPLEX_UNIT_SP,
                    9f,
                )
                label.setTextColor(R.id.widget_time_label_number, palette.textPrimary)
            } else {
                label.setViewVisibility(R.id.widget_time_label_time, View.VISIBLE)
                label.setTextViewTextSize(
                    R.id.widget_time_label_number,
                    TypedValue.COMPLEX_UNIT_SP,
                    7f,
                )
                label.setTextColor(R.id.widget_time_label_number, palette.textSecondary)
            }
            label.setTextColor(R.id.widget_time_label_time, palette.textPrimary)
            val topDp = period.virtualStartMinutes * hourHeightDp / 60.0f
            label.setViewLayoutMargin(
                R.id.widget_time_label_root,
                RemoteViews.MARGIN_TOP,
                topDp,
                TypedValue.COMPLEX_UNIT_DIP,
            )
            parent.addView(containerId, label)
        }
    }

    private fun populateGridLines(
        packageName: String,
        parent: RemoteViews,
        containerId: Int,
        hourHeightDp: Float,
        palette: WidgetPalette,
        periods: List<WidgetPeriod>,
    ) {
        // Lines at every period start + end, deduped by virtual minute so
        // adjacent periods share a line and big-break boundaries get both
        // upper and lower edges.
        val drawn = mutableSetOf<Int>()
        for (period in periods) {
            for (virtualMin in listOf(period.virtualStartMinutes, period.virtualEndMinutes)) {
                if (!drawn.add(virtualMin)) continue
                val line = RemoteViews(packageName, R.layout.widget_grid_line)
                line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", palette.divider)
                val topDp = virtualMin * hourHeightDp / 60.0f
                line.setViewLayoutMargin(
                    R.id.widget_grid_line_root,
                    RemoteViews.MARGIN_TOP,
                    topDp,
                    TypedValue.COMPLEX_UNIT_DIP,
                )
                parent.addView(containerId, line)
            }
        }
    }

    /// Faint translucent block in any virtual gap between two periods —
    /// only big breaks (Hofpause, Mittagspause) survive the mapper's
    /// small-break collapse.
    private fun populateBreakBlocks(
        packageName: String,
        parent: RemoteViews,
        containerId: Int,
        hourHeightDp: Float,
        palette: WidgetPalette,
        periods: List<WidgetPeriod>,
    ) {
        for (i in 0 until periods.size - 1) {
            val curr = periods[i]
            val next = periods[i + 1]
            val virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
            if (virtualGap <= 0) continue
            val block = RemoteViews(packageName, R.layout.widget_break_block)
            val topDp = curr.virtualEndMinutes * hourHeightDp / 60.0f
            val heightDp = virtualGap * hourHeightDp / 60.0f
            block.setViewLayoutMargin(
                R.id.widget_break_block_root,
                RemoteViews.MARGIN_TOP,
                topDp,
                TypedValue.COMPLEX_UNIT_DIP,
            )
            block.setViewLayoutHeight(
                R.id.widget_break_block_root,
                heightDp,
                TypedValue.COMPLEX_UNIT_DIP,
            )
            block.setInt(
                R.id.widget_break_block_root,
                "setBackgroundColor",
                palette.breakBlock,
            )
            parent.addView(containerId, block)
        }
    }

    private fun formatHm(minutesSinceMidnight: Int): String {
        val h = minutesSinceMidnight / 60
        val m = minutesSinceMidnight % 60
        return "%02d:%02d".format(h, m)
    }

    /// Overrides the chrome XML's `@color/widget_*` defaults when the user
    /// pins a fixed light/dark theme, and resizes the watermark M to match
    /// the current widget bounds.
    private fun applyChrome(views: RemoteViews, palette: WidgetPalette, options: Bundle?) {
        views.setInt(R.id.widget_root, "setBackgroundColor", palette.background)
        views.setInt(R.id.widget_watermark, "setColorFilter", palette.textPrimary)
        views.setFloat(R.id.widget_watermark, "setAlpha", palette.watermarkAlpha)

        val (widthDp, heightDp) = widgetSizeDp(options)
        // Sized to the longer edge so the M scales with widget resize.
        // Negative end/bottom margin lets a sliver tuck behind the edge.
        val markSize = (max(widthDp, heightDp) * 0.8f).coerceIn(160f, 400f)
        val offsetEnd = -(markSize * 0.18f)
        val offsetBottom = -(markSize * 0.18f)
        views.setViewLayoutWidth(
            R.id.widget_watermark,
            markSize,
            TypedValue.COMPLEX_UNIT_DIP,
        )
        views.setViewLayoutHeight(
            R.id.widget_watermark,
            markSize,
            TypedValue.COMPLEX_UNIT_DIP,
        )
        views.setViewLayoutMargin(
            R.id.widget_watermark,
            RemoteViews.MARGIN_END,
            offsetEnd,
            TypedValue.COMPLEX_UNIT_DIP,
        )
        views.setViewLayoutMargin(
            R.id.widget_watermark,
            RemoteViews.MARGIN_BOTTOM,
            offsetBottom,
            TypedValue.COMPLEX_UNIT_DIP,
        )
    }

    private fun widgetSizeDp(options: Bundle?): Pair<Int, Int> {
        if (options == null) return Pair(220, 220)
        val width = max(
            options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0),
            options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0),
        ).coerceAtLeast(140)
        val height = max(
            options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0),
            options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0),
        ).coerceAtLeast(140)
        return Pair(width, height)
    }

    private fun addLessonBlock(
        context: Context,
        packageName: String,
        parent: RemoteViews,
        containerId: Int,
        lesson: WidgetLesson,
        hourHeightDp: Float,
        periods: List<WidgetPeriod>,
        subjectOnly: Boolean,
        horizontalPaddingDp: Int,
    ) {
        val cal = Calendar.getInstance()
        cal.time = lesson.start
        val startMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
        cal.time = lesson.end
        val endMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
        val durationMinutes = (endMinutes - startMinutes).coerceAtLeast(15)

        val virtualStart = realMinutesToVirtual(startMinutes, periods)
        val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods)
        if (virtualEnd <= virtualStart) return

        // Half the gap above + half below so the grid line under the tile
        // stays visible.
        val topDp = virtualStart * hourHeightDp / 60.0f + LESSON_GAP_DP / 2f
        val heightDp = ((virtualEnd - virtualStart) * hourHeightDp / 60.0f - LESSON_GAP_DP)
            .coerceAtLeast(MIN_BLOCK_HEIGHT_DP.toFloat())

        val block = RemoteViews(packageName, R.layout.widget_lesson_block)
        block.setViewLayoutMargin(
            R.id.widget_lesson_block_root,
            RemoteViews.MARGIN_TOP,
            topDp,
            TypedValue.COMPLEX_UNIT_DIP,
        )
        block.setViewLayoutHeight(
            R.id.widget_lesson_block_root,
            heightDp,
            TypedValue.COMPLEX_UNIT_DIP,
        )
        block.setInt(
            R.id.widget_lesson_block_root,
            "setBackgroundResource",
            statusDrawable(lesson),
        )

        val density = context.resources.displayMetrics.density
        val padXPx = (horizontalPaddingDp * density).toInt()
        val padYPx = (3 * density).toInt()
        block.setViewPadding(
            R.id.widget_lesson_block_root,
            padXPx, padYPx, padXPx, padYPx,
        )

        block.setTextViewText(R.id.widget_lesson_subject, subjectLabel(lesson))

        // Separate fixed-size badge so the +N hint stays readable when
        // autoSize shrinks the subject on narrow tiles.
        if (lesson.siblingCount > 0) {
            block.setTextViewText(
                R.id.widget_lesson_sibling_badge,
                "+${lesson.siblingCount}",
            )
            block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.VISIBLE)
        } else {
            block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.GONE)
        }

        val room = roomLabel(lesson)
        val teacher = teacherLabel(lesson)
        val noSecondaryContent = room.isNullOrEmpty() && teacher.isNullOrEmpty()
        val hideSecondary = subjectOnly ||
            heightDp < BLOCK_SHOW_ROOM_MIN_DP ||
            noSecondaryContent
        block.setViewVisibility(
            R.id.widget_lesson_secondary_stack,
            if (hideSecondary) View.GONE else View.VISIBLE,
        )
        // Custom-events have no room/teacher → let the subject wrap to 2 lines
        // so long titles don't autoshrink to nothing.
        block.setInt(
            R.id.widget_lesson_subject,
            "setMaxLines",
            if (noSecondaryContent) 2 else 1,
        )
        when {
            hideSecondary -> {
                applyOptionalText(block, R.id.widget_lesson_room, null)
                applyOptionalText(block, R.id.widget_lesson_teacher, null)
            }
            heightDp < BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP -> {
                applyOptionalText(block, R.id.widget_lesson_room, room)
                applyOptionalText(block, R.id.widget_lesson_teacher, null)
            }
            else -> {
                applyOptionalText(block, R.id.widget_lesson_room, room)
                applyOptionalText(block, R.id.widget_lesson_teacher, teacher)
            }
        }

        parent.addView(containerId, block)
    }

    private fun applyOptionalText(views: RemoteViews, viewId: Int, text: String?) {
        if (text.isNullOrEmpty()) {
            views.setViewVisibility(viewId, View.GONE)
        } else {
            views.setTextViewText(viewId, text)
            views.setViewVisibility(viewId, View.VISIBLE)
        }
    }

    /// Custom-events use the user-picked palette (orange/red/green/blue,
    /// mirroring CustomTimetableColors).
    private fun statusDrawable(lesson: WidgetLesson): Int {
        if (lesson.status == WidgetLessonStatus.EVENT && lesson.customColor != null) {
            return when (lesson.customColor) {
                "orange" -> R.drawable.widget_lesson_block_event_orange
                "red" -> R.drawable.widget_lesson_block_event_red
                "green" -> R.drawable.widget_lesson_block_event_green
                "blue" -> R.drawable.widget_lesson_block_event_blue
                else -> R.drawable.widget_lesson_block_event_orange
            }
        }
        return when (lesson.status) {
            WidgetLessonStatus.CANCELLED -> R.drawable.widget_lesson_block_cancelled
            WidgetLessonStatus.IRREGULAR -> R.drawable.widget_lesson_block_irregular
            WidgetLessonStatus.TEACHER_CHANGED -> R.drawable.widget_lesson_block_teacher_changed
            WidgetLessonStatus.PAST -> R.drawable.widget_lesson_block_past
            WidgetLessonStatus.EVENT -> R.drawable.widget_lesson_block_event_orange
            // ONGOING collapses into REGULAR — widgets only refresh every
            // ~30min so "the current lesson" is stale most of the time and
            // the visual highlight would mislead more than help.
            WidgetLessonStatus.ONGOING,
            WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular
        }
    }

    private fun subjectLabel(lesson: WidgetLesson): String {
        return lesson.subjectShort.ifEmpty { lesson.subjectLong ?: "—" }
    }

    private fun roomLabel(lesson: WidgetLesson): String? = lesson.room

    private fun teacherLabel(lesson: WidgetLesson): String? =
        lesson.teacher ?: lesson.originalTeacher

    private fun dayLabel(context: Context, anchor: Date): String {
        val today = WidgetDateUtils.startOfDay(Date())
        val tomorrow = Calendar.getInstance().apply {
            time = today
            add(Calendar.DAY_OF_YEAR, 1)
        }.time
        val anchorStart = WidgetDateUtils.startOfDay(anchor)
        return when {
            anchorStart == today -> context.getString(R.string.widget_today)
            anchorStart == tomorrow -> context.getString(R.string.widget_tomorrow)
            else -> SimpleDateFormat("EEEE", Locale.GERMAN).format(anchor)
        }
    }

    private fun freshnessLabel(context: Context, fetchedAt: Date): String {
        val today = WidgetDateUtils.startOfDay(Date())
        val fetchedDay = WidgetDateUtils.startOfDay(fetchedAt)
        val yesterday = Calendar.getInstance().apply {
            time = today
            add(Calendar.DAY_OF_YEAR, -1)
        }.time
        val yesterdayPrefix = context.getString(R.string.widget_yesterday_prefix)
        return when (fetchedDay) {
            today -> timeFormat.format(fetchedAt)
            yesterday -> "$yesterdayPrefix ${timeFormat.format(fetchedAt)}"
            else -> dateTimeShort.format(fetchedAt)
        }
    }

    private fun buildPlaceholder(
        context: Context,
        packageName: String,
        message: String,
        palette: WidgetPalette,
    ): RemoteViews {
        val views = RemoteViews(packageName, R.layout.widget_placeholder)
        applyChrome(views, palette, null)
        views.setTextColor(R.id.widget_placeholder_title, palette.textPrimary)
        views.setTextColor(R.id.widget_placeholder_message, palette.textSecondary)
        views.setTextViewText(
            R.id.widget_placeholder_title,
            context.getString(R.string.widget_placeholder_title),
        )
        views.setTextViewText(R.id.widget_placeholder_message, message)
        views.setOnClickPendingIntent(
            R.id.widget_placeholder_message,
            openAppIntent(context),
        )
        return views
    }

    private fun openAppIntent(context: Context): PendingIntent {
        // ACTION_MAIN + LAUNCHER mirrors a launcher tap; the boolean extra
        // is consumed by Dart via WidgetNavigation to route to the timetable.
        val intent = Intent(context, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
            action = Intent.ACTION_MAIN
            addCategory(Intent.CATEGORY_LAUNCHER)
            putExtra("widget_open_timetable", true)
        }
        return PendingIntent.getActivity(
            context,
            0,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
        )
    }

    private fun sharedPrefs(context: Context): SharedPreferences {
        return context.getSharedPreferences(
            "HomeWidgetPreferences",
            Context.MODE_PRIVATE,
        )
    }

    // Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
    // schema bump must land in Dart, Swift (WidgetData.swift) and here
    // together, or the out-of-sync platform silently renders empty.
    const val KEY_DAY_DATA = "widget_data_day_v1"
    const val KEY_WEEK_DATA = "widget_data_week_v2"
    const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
    const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
}
