package app.chompass.data

import app.chompass.models.DailyFoodTotals
import app.chompass.models.FoodEntry
import app.chompass.models.FoodSource
import app.chompass.models.PendingFoodAnalysisDraft
import app.chompass.services.FoodImageStore
import app.chompass.services.PerfLog
import app.chompass.services.ReviewPrompter
import app.chompass.services.health.HealthConnectManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.time.Instant
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.UUID
import kotlin.math.roundToInt

private fun FoodEntry.month(): YearMonth = YearMonth.from(timestamp.atZone(ZoneId.systemDefault()))

/** Inclusive calendar-month span covering [start]..[end]. */
internal fun yearMonthsOverlapping(start: LocalDate, end: LocalDate): List<YearMonth> {
    if (start.isAfter(end)) return emptyList()
    val out = ArrayList<YearMonth>()
    var month = YearMonth.from(start)
    val last = YearMonth.from(end)
    while (!month.isAfter(last)) {
        out.add(month)
        month = month.plusMonths(1)
    }
    return out
}

/**
 * One [DailyFoodTotals] row per calendar day that has at least one entry,
 * preserving entry order within a day so the sums are bit-identical to the
 * old per-entry Progress grouping. Builds the aggregates cache (flippidity
 * C.1) from food month content; also used by the Progress screenshot preview.
 */
internal fun aggregateFoodEntriesByDay(
    entries: List<FoodEntry>,
    zone: ZoneId = ZoneId.systemDefault(),
): List<DailyFoodTotals> {
    val byDay = LinkedHashMap<LocalDate, MutableList<FoodEntry>>()
    for (entry in entries) {
        byDay.getOrPut(entry.timestamp.atZone(zone).toLocalDate()) { ArrayList() }.add(entry)
    }
    return byDay.map { (day, dayEntries) ->
        DailyFoodTotals(
            date = day,
            calories = dayEntries.sumOf { it.calories },
            protein = dayEntries.sumOf { it.protein },
            carbs = dayEntries.sumOf { it.carbs },
            fat = dayEntries.sumOf { it.fat },
            fiber = dayEntries.sumOf { it.fiber ?: 0.0 },
            sugar = dayEntries.sumOf { it.sugar ?: 0.0 },
            sodium = dayEntries.sumOf { it.sodium ?: 0.0 },
            addedSugar = dayEntries.sumOf { it.addedSugar ?: 0.0 },
            saturatedFat = dayEntries.sumOf { it.saturatedFat ?: 0.0 },
            cholesterol = dayEntries.sumOf { it.cholesterol ?: 0.0 },
            potassium = dayEntries.sumOf { it.potassium ?: 0.0 },
            transFat = dayEntries.sumOf { it.transFat ?: 0.0 },
            calcium = dayEntries.sumOf { it.calcium ?: 0.0 },
            iron = dayEntries.sumOf { it.iron ?: 0.0 },
            magnesium = dayEntries.sumOf { it.magnesium ?: 0.0 },
            zinc = dayEntries.sumOf { it.zinc ?: 0.0 },
            vitaminA = dayEntries.sumOf { it.vitaminA ?: 0.0 },
            vitaminC = dayEntries.sumOf { it.vitaminC ?: 0.0 },
            vitaminD = dayEntries.sumOf { it.vitaminD ?: 0.0 },
            vitaminB12 = dayEntries.sumOf { it.vitaminB12 ?: 0.0 },
            vitaminE = dayEntries.sumOf { it.vitaminE ?: 0.0 },
            vitaminK = dayEntries.sumOf { it.vitaminK ?: 0.0 },
            folate = dayEntries.sumOf { it.folate ?: 0.0 },
            omega3 = dayEntries.sumOf { it.omega3 ?: 0.0 },
            caffeine = dayEntries.sumOf { it.caffeine ?: 0.0 },
        )
    }
}

/** Months that can contain the 90-day frequent window ending at [now]. */
internal fun yearMonthsForQuickRelog(
    now: Instant,
    zone: ZoneId = ZoneId.systemDefault(),
    frequentDays: Long = 90,
): List<YearMonth> {
    val today = now.atZone(zone).toLocalDate()
    return yearMonthsOverlapping(today.minusDays(frequentDays - 1), today)
}

/**
 * CRUD + reactive reads for food entries. Port of iOS FoodStore.
 * Backed by [PreferencesStore] (entries + favorites serialized as JSON).
 */
class FoodRepository(
    private val prefs: PreferencesStore,
    private val health: HealthConnectManager? = null,
    private val imageStore: FoodImageStore? = null,
    private val sync: app.chompass.sync.SyncRepository? = null,
) {
    val entries: Flow<List<FoodEntry>> = prefs.foodEntries

    /**
     * Favorites are now stored as an ordered list of [FoodEntry] copies (not
     * a Set of keys), mirroring iOS `FoodStore.favorites`. The list owns its
     * own copies so a favorite survives deletion of the original log entry
     * and the user-defined order persists across restarts.
     *
     * Reads also trigger a one-time migration from the legacy `favoriteKeys`
     * Set if the new list is empty but the old set has entries — done via a
     * suspend [migratedFavorites] helper that the Saved Meals UI calls
     * directly when the sheet opens. That helper also collapses duplicates
     * produced by the old name|calories identity key.
     */
    val favorites: Flow<List<FoodEntry>> = prefs.favoriteFoodEntries

    /** Run the migration once and return the (possibly newly-seeded) list. */
    suspend fun migratedFavorites(): List<FoodEntry> {
        ensureFavoritesMigrated()
        return prefs.favoriteFoodEntries.first()
    }

    /**
     * Derived from [favorites] so existing call sites that read favoriteKeys
     * (Home list heart icon, Saved Meals heart icon, etc.) keep working
     * without change.
     */
    val favoriteKeys: Flow<Set<String>> = prefs.favoriteFoodEntries.map { list ->
        list.map { it.favoriteKey }.toSet()
    }

    fun entriesForDate(date: LocalDate): Flow<List<FoodEntry>> =
        prefs.foodEntriesForMonth(YearMonth.from(date)).map { monthEntries ->
            monthEntries.filter { it.timestamp.atZone(ZoneId.systemDefault()).toLocalDate() == date }
                .sortedByDescending { it.timestamp }
        }

    /** Entries whose local date is in [start]..[end], decoding only overlapping month buckets. */
    fun entriesBetween(start: LocalDate, end: LocalDate): Flow<List<FoodEntry>> {
        val zone = ZoneId.systemDefault()
        return prefs.foodEntriesForMonths(yearMonthsOverlapping(start, end)).map { monthEntries ->
            monthEntries.filter { entry ->
                val day = entry.timestamp.atZone(zone).toLocalDate()
                !day.isBefore(start) && !day.isAfter(end)
            }
        }
    }

    /**
     * Per-day calorie/macro totals for the date window, from the daily
     * aggregates cache (flippidity C.1) instead of the year of [FoodEntry]
     * rows. Progress reads these, so All-range compute never holds the full
     * diary in memory and re-emits only when a day's totals actually change.
     * Same date window as [entriesBetween]; days with no entries are absent.
     */
    fun dailyTotalsBetween(start: LocalDate, end: LocalDate): Flow<List<DailyFoodTotals>> {
        return prefs.dailyFoodTotalsForMonths(yearMonthsOverlapping(start, end)).map { totals ->
            totals.filter { !it.date.isBefore(start) && !it.date.isAfter(end) }
        }
    }

    fun entriesByMealForDate(date: LocalDate): Flow<List<Pair<String, List<FoodEntry>>>> =
        entriesForDate(date).map { dayEntries ->
            val catalog = app.chompass.models.CurrentMealCatalog.value
            val order = catalog.displayOrderIds()
            val grouped = dayEntries.groupBy { it.mealType }
            val known = order.mapNotNull { id ->
                val mealEntries = grouped[id].orEmpty()
                if (mealEntries.isEmpty()) null else id to mealEntries
            }
            val orphans = grouped.keys.filter { it !in order }.sorted().map { id ->
                id to grouped.getValue(id)
            }
            known + orphans
        }

    suspend fun addEntry(
        entry: FoodEntry,
        draft: PendingFoodAnalysisDraft? = null,
        clearDraft: Boolean = false,
        writeHealth: Boolean = true,
    ) {
        val resolved = resolveReloggedImage(entry)
        // Persistence commit — only this entry's month bucket is re-serialized
        // and written, not the whole food log, then the Health Connect IPC insert.
        // An optional confirming AI-entry draft snapshot (or its removal via
        // [clearDraft]) commits in the same edit so the save never costs an extra
        // full-file DataStore write.
        PerfLog.measure("save", "dataStore", "month=${resolved.month()}") {
            prefs.applyFoodEntryBucketChanges(
                upsertsByMonth = mapOf(resolved.month() to listOf(resolved)),
                draft = draft,
                clearDraft = clearDraft,
            )
        }
        sync?.touch(resolved.id, "food")
        if (writeHealth && shouldSyncHealth()) {
            PerfLog.measure("save", "healthWrite") { health?.writeNutrition(resolved) }
        }
        // One-time organic review moment: the first successful food log (iOS parity).
        if (!prefs.reviewPromptedAfterFirstLog.first()) {
            prefs.setReviewPromptedAfterFirstLog(true)
            ReviewPrompter.requestReview.value = true
        }
    }

    /**
     * Bulk add (progressive-meal Log meal / Copy From Day) in a single DataStore
     * edit (one full-file write regardless of how many entries share the month)
     * plus one batched sync-revision edit. Health Connect mirroring is skipped
     * when [writeHealth] is false — callers then run [mirrorEntryToHealth] in the
     * background after the UI has already dismissed.
     */
    suspend fun addEntries(entries: List<FoodEntry>, writeHealth: Boolean = true) {
        if (entries.isEmpty()) return
        val resolved = entries.map { resolveReloggedImage(it) }
        PerfLog.measure("save", "dataStore", "count=${resolved.size}") {
            prefs.applyFoodEntryBucketChanges(upsertsByMonth = resolved.groupBy { it.month() })
        }
        sync?.touchMany(resolved.map { it.id to "food" })
        if (writeHealth && shouldSyncHealth()) {
            PerfLog.measure("save", "healthWrite", "count=${resolved.size}") {
                resolved.forEach { health?.writeNutrition(it) }
            }
        }
        // One-time organic review moment: the first successful food log (iOS parity).
        if (!prefs.reviewPromptedAfterFirstLog.first()) {
            prefs.setReviewPromptedAfterFirstLog(true)
            ReviewPrompter.requestReview.value = true
        }
    }

    /**
     * #12: a duplicated-for-logging entry carries the source row's imageFilename.
     * Copy the JPEG to the new entry's own filename on save — relogs keep the
     * photo, while later re-picks/deletes of either row stay independent and
     * pruneUnreferenced() never removes a file the new row still points at.
     * Entries whose image is already stored under their own id are untouched.
     */
    private fun resolveReloggedImage(entry: FoodEntry): FoodEntry {
        val filename = entry.imageFilename ?: return entry
        val expected = FoodImageStore.filenameFor(entry.id)
        if (filename == expected) return entry
        val copied = imageStore?.copyForEntry(filename, entry.id)
        return if (copied != null) entry.copy(imageFilename = copied)
        else entry.copy(imageFilename = null) // stale reference / copy failure -> no image
    }

    /**
     * Health Connect mirror write for a row already committed locally, gated on
     * the user's sync toggle. Safe to run in the background — failures are
     * swallowed by the writer (best-effort mirror, same as the inline path).
     */
    suspend fun mirrorEntryToHealth(entry: FoodEntry) {
        if (shouldSyncHealth()) {
            PerfLog.measure("save", "healthWrite") { health?.writeNutrition(entry) }
        }
    }

    suspend fun updateEntry(original: FoodEntry, updated: FoodEntry) {
        val oldMonth = original.month()
        val newMonth = updated.month()
        // Codeberg #56 repro instrumentation (TEMP, debug-only).
        PerfLog.event(
            "op=editEntry phase=updateEntry id=${updated.id.toString().take(8)} " +
                "meal=${original.mealType}->${updated.mealType} " +
                "ts=${original.timestamp.epochSecond}->${updated.timestamp.epochSecond} " +
                "month=$oldMonth->$newMonth",
        )
        if (oldMonth == newMonth) {
            prefs.applyFoodEntryBucketChanges(upsertsByMonth = mapOf(newMonth to listOf(updated)))
        } else {
            prefs.applyFoodEntryBucketChanges(
                upsertsByMonth = mapOf(newMonth to listOf(updated)),
                removalIdsByMonth = mapOf(oldMonth to setOf(updated.id)),
            )
        }
        PerfLog.event("op=editEntry phase=updateEntryDone id=${updated.id.toString().take(8)}")
        sync?.touch(updated.id, "food")
        if (shouldSyncHealth()) {
            health?.updateNutrition(updated)
        } else {
            // Sync off: still clean up the stale HC record for this entry (iOS
            // parity, best-effort) so the restore path can't resurrect the
            // pre-edit version later.
            health?.deleteNutrition(updated.id)
        }
    }

    suspend fun deleteEntry(entry: FoodEntry) {
        prefs.applyFoodEntryBucketChanges(removalIdsByMonth = mapOf(entry.month() to setOf(entry.id)))
        sync?.tombstone(entry.id, "food")
        pruneOrphanedImages()
        // Delete even when sync is off (iOS parity, best-effort) — a surviving
        // fudai-tagged record would resurrect through restoreFromHealthConnect.
        health?.deleteNutrition(entry.id)
    }

    suspend fun replaceAll(entries: List<FoodEntry>) {
        prefs.replaceAllFoodEntries(entries)
        pruneOrphanedImages()
    }

    /**
     * Bulk-import diary entries into the existing log in a single write.
     * We preserve current rows and dedupe only by id to avoid clobbering
     * user edits that share similar nutrition values.
     */
    suspend fun importEntries(entries: List<FoodEntry>): Int {
        if (entries.isEmpty()) return 0
        val existingIds = prefs.foodEntries.first().asSequence().map { it.id }.toHashSet()
        val incoming = entries.filter { existingIds.add(it.id) }
        if (incoming.isEmpty()) return 0
        prefs.applyFoodEntryBucketChanges(upsertsByMonth = incoming.groupBy { it.month() })
        return incoming.size
    }

    suspend fun clear() {
        prefs.replaceAllFoodEntries(emptyList())
        pruneOrphanedImages()
    }

    // -- Favorites --------------------------------------------------------

    suspend fun isFavorite(entry: FoodEntry): Boolean {
        return prefs.favoriteFoodEntries.first().any { it.favoriteKey == entry.favoriteKey }
    }

    /**
     * Toggle favorite status by favoriteKey. Mirrors iOS
     * FoodStore.toggleFavorite — if a favorite with the same favoriteKey
     * exists, remove it; otherwise append a *copy* of [entry] to the list.
     * The legacy `favoriteKeys` Set is also kept in sync for any older code
     * paths still reading it directly.
     */
    suspend fun toggleFavorite(entry: FoodEntry) {
        ensureFavoritesMigrated()
        val current = prefs.favoriteFoodEntries.first().toMutableList()
        val idx = current.indexOfFirst { it.favoriteKey == entry.favoriteKey }
        if (idx >= 0) {
            val removed = current.removeAt(idx)
            prefs.setFavoriteFoodEntries(current)
            prefs.setFavoriteKeys(current.map { it.favoriteKey }.toSet())
            sync?.tombstone(removed.id, "favorite")
            pruneOrphanedImages()
            return
        } else {
            // Drop any other entry with the same id (defensive — should not
            // normally happen since we matched by favoriteKey above).
            current.removeAll { it.id == entry.id }
            current.add(entry)
        }
        prefs.setFavoriteFoodEntries(current)
        prefs.setFavoriteKeys(current.map { it.favoriteKey }.toSet())
        sync?.touch(entry.id, "favorite")
    }

    /**
     * Reorder a favorite from index [from] to index [to]. Mirrors iOS
     * FoodStore.moveFavorite using SwiftUI's `Array.move(fromOffsets:toOffset:)`
     * semantics — [to] is the *destination* index in the post-removal list.
     */
    suspend fun moveFavorite(from: Int, to: Int) {
        ensureFavoritesMigrated()
        val list = prefs.favoriteFoodEntries.first().toMutableList()
        if (from !in list.indices) return
        val item = list.removeAt(from)
        val safeTo = to.coerceIn(0, list.size)
        list.add(safeTo, item)
        prefs.setFavoriteFoodEntries(list)
    }

    /**
     * Identity keys a rename of [favorite] must avoid (Codeberg #66): every
     * diary + favorite identity except this favorite's own current key, so
     * renaming to itself stays valid while any other food's name collides.
     */
    suspend fun favoriteRenameBlocklist(favorite: FoodEntry): Set<String> =
        existingFoodIdentityKeys() - favorite.favoriteKey

    /**
     * Update a stored favorite in place (Codeberg #66) — favorites behave as
     * a personal saved-foods library, not one-shot diary snapshots. [original]
     * is matched by id (fallback: favoriteKey, so stale UI snapshots still
     * hit) and replaced by [updated] at the same position in the list.
     *
     * The stored favorite's id is kept even when [updated] carries another:
     * renames change [FoodEntry.favoriteKey], and the sync revision chain
     * (LWW by updatedAt, kind "favorite") is keyed by id. recipeLogId is
     * normalized to null — a favorite is a standalone saved food, never a
     * recipe member (PWA toggleFavorite parity). The legacy favoriteKeys set
     * is rewritten alongside, sync touch notifies other devices, and a
     * replaced photo filename is deleted once nothing else references it.
     *
     * @return the stored favorite, or null when no match existed (already
     *         unfavorited elsewhere / migrated away).
     */
    suspend fun updateFavorite(original: FoodEntry, updated: FoodEntry): FoodEntry? {
        ensureFavoritesMigrated()
        val current = prefs.favoriteFoodEntries.first().toMutableList()
        val idx = current.indexOfFirst { it.id == original.id || it.favoriteKey == original.favoriteKey }
        if (idx < 0) return null
        val storedId = current[idx].id
        val stored = updated.copy(id = storedId, recipeLogId = null)
        current[idx] = stored
        prefs.setFavoriteFoodEntries(current)
        prefs.setFavoriteKeys(current.map { it.favoriteKey }.toSet())
        sync?.touch(storedId, "favorite")
        if (stored.imageFilename != original.imageFilename) {
            deleteImageIfUnreferenced(original.imageFilename)
        }
        return stored
    }

    /**
     * One-time migration: if the new ordered favoriteFoodEntries list is
     * empty but the legacy favoriteKeys Set has entries, reconstruct the
     * ordered list from current food log entries (best-effort — no preserved
     * order since the old format never tracked one).
     *
     * Also collapses duplicate favorites that shared a name under the old
     * `name|calories` identity (same food, different servings), and rewrites
     * the legacy key set to the name-only identity.
     */
    private suspend fun ensureFavoritesMigrated() {
        val ordered = prefs.favoriteFoodEntries.first()
        if (ordered.isEmpty()) {
            val legacy = prefs.favoriteKeys.first()
            if (legacy.isEmpty()) return
            val all = prefs.foodEntries.first()
            val seeded = legacy.mapNotNull { key ->
                all.firstOrNull { matchesFavoriteIdentity(it, key) }
            }.let { dedupeFavoritesByIdentity(it) }
            if (seeded.isEmpty()) return
            prefs.setFavoriteFoodEntries(seeded)
            prefs.setFavoriteKeys(seeded.map { it.favoriteKey }.toSet())
            return
        }
        val deduped = dedupeFavoritesByIdentity(ordered)
        val newKeys = deduped.map { it.favoriteKey }.toSet()
        val oldKeys = prefs.favoriteKeys.first()
        if (deduped.size != ordered.size || newKeys != oldKeys) {
            prefs.setFavoriteFoodEntries(deduped)
            prefs.setFavoriteKeys(newKeys)
        }
    }

    /** Keep first occurrence per [FoodEntry.favoriteKey] (preserves user order). */
    private fun dedupeFavoritesByIdentity(entries: List<FoodEntry>): List<FoodEntry> {
        val seen = mutableSetOf<String>()
        return entries.filter { it.favoriteKey.isNotEmpty() && seen.add(it.favoriteKey) }
    }

    /**
     * Match a stored favorite key against an entry. Accepts the current
     * name-only identity and the legacy `name|calories` form.
     */
    private fun matchesFavoriteIdentity(entry: FoodEntry, storedKey: String): Boolean {
        if (entry.favoriteKey == storedKey) return true
        val namePart = storedKey.substringBefore('|').trim().lowercase()
        return namePart.isNotEmpty() && entry.favoriteKey == namePart
    }

    private suspend fun shouldSyncHealth(): Boolean {
        val manager = health ?: return false
        return prefs.healthConnectEnabled.first() && manager.hasNutritionWrite()
    }

    /** Drop on-disk JPEGs once no log row, favorite, or draft still references them. */
    suspend fun pruneOrphanedImages() {
        val store = imageStore ?: return
        ensureFavoritesMigrated()
        val referenced = prefs.foodImageReferenceFilenames() ?: return
        store.pruneUnreferenced(referenced)
    }

    private suspend fun deleteImageIfUnreferenced(imageFilename: String?) {
        val filename = imageFilename ?: return
        val store = imageStore ?: return
        if (prefs.foodEntries.first().any { it.imageFilename == filename }) return
        if (prefs.favoriteFoodEntries.first().any { it.imageFilename == filename }) return
        if (prefs.pendingFoodAnalysisDraft.first()?.imageFilename == filename) return
        if (prefs.pendingFoodInputDraft.first()?.imageFilename == filename) return
        store.delete(filename)
    }

    // -- Restore from Health Connect --------------------------------------

    /**
     * Rebuilds the food log from the NutritionRecords Fud AI itself wrote to
     * Health Connect — the restore path after a reinstall or new phone, where
     * Health Connect data survives but app storage doesn't. Only records
     * carrying our fudai_(uuid) clientRecordId are considered; the original
     * entry UUID is recovered from the tag so future edits and deletes still
     * target the matching HC record. Ids already in the log and nameless
     * records are skipped, and nothing is written back to Health Connect.
     * Photos, emojis, notes and serving units aren't in HC and don't return.
     */
    suspend fun restoreFromHealthConnect(external: List<app.chompass.services.health.ExternalNutrition>) {
        val manager = health ?: return
        val current = prefs.foodEntries.first()
        val existingIds = current.map { it.id }.toSet()
        val restored = external.mapNotNull { record ->
            val id = manager.ownRecordId(record.clientRecordId) ?: return@mapNotNull null
            if (id in existingIds) return@mapNotNull null
            val name = record.name?.trim().orEmpty()
            if (name.isEmpty()) return@mapNotNull null
            FoodEntry(
                id = id,
                name = name,
                calories = (record.calories ?: 0.0).roundToInt(),
                protein = record.protein ?: 0.0,
                carbs = record.carbs ?: 0.0,
                fat = record.fat ?: 0.0,
                timestamp = record.time,
                source = FoodSource.MANUAL,
                mealType = record.mealType.id,
                sugar = record.sugar,
                fiber = record.fiber,
                saturatedFat = record.saturatedFat,
                monounsaturatedFat = record.monounsaturatedFat,
                polyunsaturatedFat = record.polyunsaturatedFat,
                cholesterol = record.cholesterol,
                sodium = record.sodium,
                potassium = record.potassium,
                transFat = record.transFat,
                calcium = record.calcium,
                iron = record.iron,
                magnesium = record.magnesium,
                zinc = record.zinc,
                vitaminA = record.vitaminA,
                vitaminC = record.vitaminC,
                vitaminD = record.vitaminD,
                vitaminB12 = record.vitaminB12,
                vitaminE = record.vitaminE,
                vitaminK = record.vitaminK,
                folate = record.folate
            )
        }
        if (restored.isEmpty()) return
        prefs.applyFoodEntryBucketChanges(upsertsByMonth = restored.groupBy { it.month() })
    }

    /**
     * Live-merge meals other apps wrote to Health Connect (the change-token path;
     * own fudai_ records are already filtered at the manager level). Same idempotent
     * upsert scheme as [WeightRepository.importExternalWeights]: a deterministic id
     * per external record, so re-imports and in-place edits update instead of
     * duplicating. Nameless records are skipped — they can't be shown in the log.
     */
    suspend fun importExternalNutrition(external: List<app.chompass.services.health.ExternalNutrition>) {
        if (external.isEmpty()) return
        val incoming = external.mapNotNull { record ->
            val name = record.name?.trim().orEmpty()
            if (name.isEmpty()) return@mapNotNull null
            FoodEntry(
                id = externalId(record.clientRecordId, record.recordId, record.time),
                name = name,
                calories = (record.calories ?: 0.0).roundToInt(),
                protein = record.protein ?: 0.0,
                carbs = record.carbs ?: 0.0,
                fat = record.fat ?: 0.0,
                timestamp = record.time,
                source = FoodSource.MANUAL,
                mealType = record.mealType.id,
                sugar = record.sugar,
                fiber = record.fiber,
                saturatedFat = record.saturatedFat,
                monounsaturatedFat = record.monounsaturatedFat,
                polyunsaturatedFat = record.polyunsaturatedFat,
                cholesterol = record.cholesterol,
                sodium = record.sodium,
                potassium = record.potassium,
                transFat = record.transFat,
                calcium = record.calcium,
                iron = record.iron,
                magnesium = record.magnesium,
                zinc = record.zinc,
                vitaminA = record.vitaminA,
                vitaminC = record.vitaminC,
                vitaminD = record.vitaminD,
                vitaminB12 = record.vitaminB12,
                vitaminE = record.vitaminE,
                vitaminK = record.vitaminK,
                folate = record.folate
            )
        }
        if (incoming.isEmpty()) return
        val existingById = prefs.foodEntries.first().associateBy { it.id }
        val changed = incoming.filter { existingById[it.id] != it }
        if (changed.isEmpty()) return
        // A record can carry a new timestamp on re-sync, moving it to a different
        // month bucket than the one it's currently stored in — remove the stale
        // copy from its old bucket alongside upserting the new one.
        val staleRemovals = changed.mapNotNull { entry ->
            val existingMonth = existingById[entry.id]?.month() ?: return@mapNotNull null
            existingMonth.takeIf { it != entry.month() }?.let { it to entry.id }
        }.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
        prefs.applyFoodEntryBucketChanges(
            upsertsByMonth = changed.groupBy { it.month() },
            removalIdsByMonth = staleRemovals,
        )
    }

    /** Stable id for an external nutrition record — see [WeightRepository.externalId]:
     *  clientRecordId, then the HC record id, then the timestamp; never the values. */
    private fun externalId(clientRecordId: String?, recordId: String, time: Instant): UUID {
        val seed = clientRecordId?.takeIf { it.isNotBlank() }
            ?: recordId.takeIf { it.isNotBlank() }
            ?: "hc-nutrition:${time.toEpochMilli()}"
        return UUID.nameUUIDFromBytes(seed.toByteArray())
    }

    // -- Recents / Frequent ---------------------------------------------

    /** Identity keys already taken by diary rows and favorites. */
    suspend fun existingFoodIdentityKeys(): Set<String> {
        val diary = prefs.foodEntries.first().mapNotNull { it.favoriteKey.takeIf { k -> k.isNotEmpty() } }
        val favs = prefs.favoriteFoodEntries.first().mapNotNull { it.favoriteKey.takeIf { k -> k.isNotEmpty() } }
        return diary.toSet() + favs
    }

    /**
     * Newest diary rows, collapsed by [FoodEntry.favoriteKey] so re-logging
     * the same food at a new serving does not stack duplicate picker rows.
     * Template for each food is the most recent log (latest grams/units).
     */
    suspend fun recent(days: Int = 30, now: Instant = Instant.now()): List<FoodEntry> {
        val cutoff = now.minus(days.toLong(), ChronoUnit.DAYS)
        return recentFoodTemplates(
            prefs.foodEntries.first().filter { !it.timestamp.isBefore(cutoff) },
        )
    }

    /**
     * Groups diary rows by food identity ([FoodEntry.favoriteKey]). Count is
     * how often that food was logged; template is the newest serving snapshot
     * so a re-log with new grams/pieces becomes the amount offered next time.
     */
    suspend fun frequent(days: Int = 90, now: Instant = Instant.now()): List<FrequentFoodGroup> {
        val cutoff = now.minus(days.toLong(), ChronoUnit.DAYS)
        return frequentFoodGroups(
            prefs.foodEntries.first().filter { !it.timestamp.isBefore(cutoff) },
        )
    }

    /**
     * All-time diary collapse (newest row per [FoodEntry.favoriteKey]) for
     * Saved Meals Recents and search. No 30-day cut, no cap. Hub chips stay
     * windowed via [quickRelogRows].
     *
     * The sheet loads it once per Recents/Frequent tab open and filters
     * in memory — same cost profile as CopyFromDaySheet's all-history load.
     */
    suspend fun historyTemplates(): List<FoodEntry> = recentFoodTemplates(
        prefs.foodEntries.first(),
        limit = Int.MAX_VALUE,
    )

    /**
     * Hub chips for one-tap re-log: a recents row (newest, 30 days) and a
     * frequent row (by count, 90 days) with no shared [FoodEntry.favoriteKey].
     * Meal-window ranking stays on Saved Meals tabs, not the hub.
     *
     * Reads the diary snapshot once and reuses it for both windows.
     */
    suspend fun quickRelogRows(perRow: Int = 10): QuickRelogRows =
        PerfLog.measure("hubOpen", "quickRelog", "perRow=$perRow") {
            val now = Instant.now()
            val months = yearMonthsForQuickRelog(now)
            val windowed = prefs.foodEntriesForMonths(months).first()
            val recentStart = now.minus(30, ChronoUnit.DAYS)
            val frequentStart = now.minus(90, ChronoUnit.DAYS)
            quickRelogRows(
                recentWindow = windowed.filter { !it.timestamp.isBefore(recentStart) },
                frequentWindow = windowed.filter { !it.timestamp.isBefore(frequentStart) },
                perRow = perRow,
            )
        }
}

/**
 * Pure Recents collapse used by [FoodRepository.recent] — newest-first,
 * one row per [FoodEntry.favoriteKey].
 */
internal fun recentFoodTemplates(entries: List<FoodEntry>, limit: Int = 50): List<FoodEntry> {
    val seen = mutableSetOf<String>()
    return entries
        .sortedByDescending { it.timestamp }
        .filter { it.favoriteKey.isNotEmpty() && seen.add(it.favoriteKey) }
        .take(limit)
}

/**
 * Two hub rows from already-windowed diary snapshots. Recents are newest
 * first and unique by [FoodEntry.favoriteKey]; frequents are count-desc
 * (then name) and skip keys already shown in recents.
 */
internal fun quickRelogRows(
    recentWindow: List<FoodEntry>,
    frequentWindow: List<FoodEntry>,
    perRow: Int = 10,
): QuickRelogRows {
    val recents = recentFoodTemplates(recentWindow, limit = perRow)
    val recentKeys = recents.mapNotNull { it.favoriteKey.takeIf(String::isNotEmpty) }.toSet()
    val frequents = frequentFoodGroups(frequentWindow)
        .asSequence()
        .filter { it.template.favoriteKey.isNotEmpty() && it.template.favoriteKey !in recentKeys }
        .take(perRow)
        .map { it.template }
        .toList()
    return QuickRelogRows(recents, frequents)
}

/**
 * Pure Frequent aggregation used by [FoodRepository.frequent].
 */
internal fun frequentFoodGroups(entries: List<FoodEntry>): List<FrequentFoodGroup> {
    val aggregates = mutableMapOf<String, Pair<Int, FoodEntry>>()
    for (entry in entries) {
        val key = entry.favoriteKey
        if (key.isEmpty()) continue
        val existing = aggregates[key]
        if (existing != null) {
            val (count, template) = existing
            val newTemplate = if (entry.timestamp > template.timestamp) entry else template
            aggregates[key] = (count + 1) to newTemplate
        } else {
            aggregates[key] = 1 to entry
        }
    }
    return aggregates.map { (_, pair) ->
        FrequentFoodGroup(template = pair.second, count = pair.first)
    }.sortedWith(
        compareByDescending<FrequentFoodGroup> { it.count }.thenBy { it.name.lowercase() }
    )
}

/**
 * Avoid accidental Saved Meals identity collisions for brand-new foods
 * (scan / AI / manual). If [desired] already exists (case-insensitive),
 * returns "Name (2)", "Name (3)", … based on the stem without a trailing
 * numeric suffix. Relogs should keep the original name so servings merge.
 */
fun disambiguateFoodName(desired: String, existingKeys: Set<String>): String {
    val trimmed = desired.trim()
    if (trimmed.isEmpty()) return desired
    val key = trimmed.lowercase()
    if (key !in existingKeys) return trimmed

    val stem = TRAILING_NUMERIC_SUFFIX.replace(trimmed, "").trim().ifEmpty { trimmed }
    var n = 2
    while (n <= 10_000) {
        val candidate = "$stem ($n)"
        if (candidate.lowercase() !in existingKeys) return candidate
        n++
    }
    return "$stem ($n)"
}

private val TRAILING_NUMERIC_SUFFIX = Regex("""\s+\((\d+)\)$""")

data class QuickRelogRows(
    val recents: List<FoodEntry>,
    val frequents: List<FoodEntry>,
) {
    val isEmpty: Boolean get() = recents.isEmpty() && frequents.isEmpty()

    companion object {
        val Empty = QuickRelogRows(emptyList(), emptyList())
    }
}

data class FrequentFoodGroup(
    val template: FoodEntry,
    val count: Int
) {
    val id: String = template.favoriteKey
    val name: String = template.name
    val calories: Int = template.calories
}

// Helper — converts Instant -> start-of-day in system zone.
@Suppress("unused")
internal fun Instant.toLocalDate(): LocalDate =
    this.atZone(ZoneId.systemDefault()).toLocalDate()
