package dev.ulfrx.recipe.shared.domain.model

import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
 * Wire-format contract for the reshaped domain model.
 *
 * The sync engine (Phase 4) stores these shapes verbatim into Postgres JSONB
 * columns; the planner UI (Phase 6/7) round-trips them through SQLDelight; a
 * future client-side catalog editor would write through the same path. So the
 * JSON keys, value-class flattening, enum casing, and default-omission behavior
 * asserted below are load-bearing.
 */
class SerializationRoundTripTest {
    private val json = Json { encodeDefaults = false }

    private val pl = LocaleTag("pl")
    private val en = LocaleTag("en")

    // ── IDs ──────────────────────────────────────────────────────────────

    @Test
    fun `typed IDs serialize as bare strings instead of wrapper objects`() {
        val encoded = json.encodeToString(RecipeId.serializer(), RecipeId("rcp_007"))

        assertEquals("\"rcp_007\"", encoded)

        val decoded = json.decodeFromString(RecipeId.serializer(), encoded)
        assertEquals(RecipeId("rcp_007"), decoded)
    }

    // ── Localization ─────────────────────────────────────────────────────

    @Test
    fun `LocaleTag serializes as a bare string key`() {
        // LocaleTag must serialize as a string so LocalizedString JSON keys
        // read like {"pl":"Śniadanie"} — not {"raw":"pl"} wrappers.
        val encoded = json.encodeToString(LocaleTag.serializer(), pl)
        assertEquals("\"pl\"", encoded)
    }

    @Test
    fun `LocalizedString forLocale falls back to Polish then to any value`() {
        val name: LocalizedString = mapOf(pl to "Śniadanie", en to "Breakfast")
        assertEquals("Śniadanie", name.forLocale(pl))
        assertEquals("Breakfast", name.forLocale(en))

        val onlyPolish: LocalizedString = mapOf(pl to "Obiad")
        assertEquals("Obiad", onlyPolish.forLocale(en))

        val empty: LocalizedString = emptyMap()
        assertEquals("", empty.forLocale(pl))
    }

    // ── Units ────────────────────────────────────────────────────────────

    @Test
    fun `MeasurementUnit serializes as lowercase English wire names`() {
        assertEquals("\"gram\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.GRAM))
        assertEquals("\"milliliter\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.MILLILITER))
        assertEquals("\"piece\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.PIECE))
    }

    @Test
    fun `Quantity with displayHint encodes hint as flat locale map`() {
        val q = Quantity(amount = 30.0, unit = MeasurementUnit.MILLILITER, displayHint = mapOf(pl to "2 łyżki"))
        val encoded = json.encodeToString(Quantity.serializer(), q)

        assertEquals(
            "{\"amount\":30.0,\"unit\":\"milliliter\",\"displayHint\":{\"pl\":\"2 łyżki\"}}",
            encoded,
        )

        val decoded = json.decodeFromString(Quantity.serializer(), encoded)
        assertEquals(q, decoded)
    }

    @Test
    fun `Quantity without displayHint omits the null field on the wire`() {
        // Phase 4 syncs these into JSONB; omitting nulls keeps the payload
        // small and lets future fields default cleanly on the decoder side.
        val q = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM)
        val encoded = json.encodeToString(Quantity.serializer(), q)

        assertEquals("{\"amount\":100.0,\"unit\":\"gram\"}", encoded)
        assertEquals(q, json.decodeFromString(Quantity.serializer(), encoded))
    }

    // ── PlanEntry — the full customization shape ─────────────────────────

    @Test
    fun `PlanEntry carries a materialized ingredient snapshot and round trips`() {
        // The snapshot model collapses substitutions, exclusions, amount overrides,
        // product picks, and added ingredients into a single ingredient list. The
        // recipe's original list is never consulted at read time, so each of these
        // "customization kinds" is just whatever ends up here:
        //   - butter → margarine swap: margarine appears, butter doesn't
        //   - salt excluded: salt simply isn't in the list
        //   - flour amount override: the quantity here IS the amount
        //   - sugar with pinned product: productPick set on the sugar row
        //   - oil added (not in the recipe): just another row
        val flour = IngredientId("ing_flour")
        val margarine = IngredientId("ing_margarine")
        val sugar = IngredientId("ing_sugar")
        val oil = IngredientId("ing_oil")
        val piatnica = ProductId("prd_piatnica_serek")

        val entry =
            PlanEntry(
                id = PlanEntryId("pe_001"),
                sync =
                    SyncMeta(
                        id = "pe_001",
                        householdId = HouseholdId("hh_aaa"),
                        createdAt = Instant.parse("2026-05-19T08:00:00Z"),
                        updatedAt = Instant.parse("2026-05-19T08:00:00Z"),
                    ),
                date = LocalDate(2026, 5, 19),
                slotId = MealSlotId("slot_breakfast"),
                recipeId = RecipeId("rcp_pancakes"),
                servings = 2.0,
                ingredients =
                    listOf(
                        PlanIngredient(
                            ingredientId = flour,
                            quantity =
                                Quantity(
                                    amount = 250.0,
                                    unit = MeasurementUnit.GRAM,
                                    displayHint = mapOf(pl to "2 szklanki"),
                                ),
                        ),
                        PlanIngredient(
                            ingredientId = margarine,
                            quantity = Quantity(amount = 50.0, unit = MeasurementUnit.GRAM),
                        ),
                        PlanIngredient(
                            ingredientId = sugar,
                            quantity = Quantity(amount = 30.0, unit = MeasurementUnit.GRAM),
                            productPick = piatnica,
                        ),
                        PlanIngredient(
                            ingredientId = oil,
                            quantity =
                                Quantity(
                                    amount = 15.0,
                                    unit = MeasurementUnit.MILLILITER,
                                    displayHint = mapOf(pl to "1 łyżka"),
                                ),
                        ),
                    ),
            )

        val encoded = json.encodeToString(PlanEntry.serializer(), entry)
        val decoded = json.decodeFromString(PlanEntry.serializer(), encoded)

        assertEquals(entry, decoded)

        // Spot-check wire shape: typed IDs are bare strings inside the payload
        // (otherwise the JSONB column gets ugly and queries are unportable).
        assertTrue(encoded.contains("\"recipeId\":\"rcp_pancakes\""), "recipeId should serialize as a bare string: $encoded")
        assertTrue(encoded.contains("\"slotId\":\"slot_breakfast\""), "slotId should serialize as a bare string: $encoded")
        assertTrue(encoded.contains("\"productPick\":\"prd_piatnica_serek\""), "productPick should serialize as a bare string: $encoded")
    }

    @Test
    fun `PlanIngredient without productPick omits the null field`() {
        val ingredient =
            PlanIngredient(
                ingredientId = IngredientId("ing_flour"),
                quantity = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM),
            )
        val encoded = json.encodeToString(PlanIngredient.serializer(), ingredient)

        assertEquals(
            "{\"ingredientId\":\"ing_flour\",\"quantity\":{\"amount\":100.0,\"unit\":\"gram\"}}",
            encoded,
        )
        assertEquals(ingredient, json.decodeFromString(PlanIngredient.serializer(), encoded))
    }

    // ── Catalog ──────────────────────────────────────────────────────────

    @Test
    fun `Ingredient with multi-locale name and required pantryUnit round trips`() {
        val ingredient =
            Ingredient(
                id = IngredientId("ing_olej"),
                sync =
                    SyncMeta(
                        id = "ing_olej",
                        householdId = HouseholdId("hh_aaa"),
                        createdAt = Instant.parse("2026-04-01T00:00:00Z"),
                        updatedAt = Instant.parse("2026-04-01T00:00:00Z"),
                    ),
                name = mapOf(pl to "Olej rzepakowy", en to "Rapeseed oil"),
                categoryId = IngredientCategoryId("cat_suche"),
                pantryUnit = MeasurementUnit.MILLILITER,
                purchasePack = PurchasePack(amount = 1000.0, label = "1 l"),
                nutritionPer100 = NutritionPer100(kcal = 884.0, protein = 0.0, fat = 100.0, carbs = 0.0),
            )

        val encoded = json.encodeToString(Ingredient.serializer(), ingredient)
        val decoded = json.decodeFromString(Ingredient.serializer(), encoded)

        assertEquals(ingredient, decoded)
        assertTrue(
            encoded.contains("\"name\":{\"pl\":\"Olej rzepakowy\",\"en\":\"Rapeseed oil\"}"),
            "LocalizedString should serialize as a flat locale-keyed object: $encoded",
        )
        assertTrue(encoded.contains("\"pantryUnit\":\"milliliter\""))
        assertTrue(!encoded.contains("\"weightPerPieceG\""), "null weightPerPieceG should be omitted: $encoded")
        assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded")
    }

    @Test
    fun `Recipe drops null imagePath but keeps populated localized title and steps`() {
        val recipe =
            Recipe(
                id = RecipeId("rcp_naleśniki"),
                sync =
                    SyncMeta(
                        id = "rcp_naleśniki",
                        householdId = HouseholdId("hh_aaa"),
                        createdAt = Instant.parse("2026-04-01T00:00:00Z"),
                        updatedAt = Instant.parse("2026-04-01T00:00:00Z"),
                    ),
                title = mapOf(pl to "Naleśniki", en to "Pancakes"),
                minutes = 25,
                tags = listOf("śniadanie", "szybkie"),
                steps =
                    listOf(
                        mapOf(pl to "Wymieszaj mąkę z mlekiem.", en to "Mix flour with milk."),
                        mapOf(pl to "Smaż na patelni.", en to "Fry on a pan."),
                    ),
                ingredients =
                    listOf(
                        RecipeIngredient(
                            ingredientId = IngredientId("ing_flour"),
                            quantity = Quantity(amount = 200.0, unit = MeasurementUnit.GRAM),
                        ),
                    ),
                nutritionPerServing = NutritionPer100(kcal = 320.0, protein = 8.0, fat = 9.0, carbs = 52.0),
            )

        val encoded = json.encodeToString(Recipe.serializer(), recipe)
        val decoded = json.decodeFromString(Recipe.serializer(), encoded)

        assertEquals(recipe, decoded)
        assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded")
        assertTrue(encoded.contains("\"tags\":[\"śniadanie\",\"szybkie\"]"))
    }

    // ── Sync ─────────────────────────────────────────────────────────────

    @Test
    fun `SyncMeta omits null deletedAt and surfaces all three timestamps`() {
        val sync =
            SyncMeta(
                id = "pe_001",
                householdId = HouseholdId("hh_aaa"),
                createdAt = Instant.parse("2026-05-19T08:00:00Z"),
                updatedAt = Instant.parse("2026-05-19T08:15:00Z"),
            )

        val encoded = json.encodeToString(SyncMeta.serializer(), sync)

        assertTrue(encoded.contains("\"createdAt\":\"2026-05-19T08:00:00Z\""))
        assertTrue(encoded.contains("\"updatedAt\":\"2026-05-19T08:15:00Z\""))
        assertTrue(!encoded.contains("\"deletedAt\""), "null deletedAt should be omitted: $encoded")

        val tombstoned = sync.copy(deletedAt = Instant.parse("2026-05-20T09:00:00Z"))
        val tombstoneEncoded = json.encodeToString(SyncMeta.serializer(), tombstoned)
        assertTrue(tombstoneEncoded.contains("\"deletedAt\":\"2026-05-20T09:00:00Z\""))
    }
}
