Skip to main content

grub_core/
db.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::{Datelike, Local, NaiveDate};
6use rusqlite::{Connection, params};
7use uuid::Uuid;
8
9use crate::models::{
10    DailySummary, DailyTarget, ExportData, ExportMealEntry, ExportRecipe, ExportRecipeIngredient,
11    ExportTarget, ExportWeightEntry, Food, ImportSummary, MEAL_TYPES, MealEntry, MealGroup,
12    NewFood, NewMealEntry, NewWeightEntry, RecentFood, Recipe, RecipeDetail, RecipeIngredient,
13    SyncPayload, SyncTombstone, UpdateMealEntry, WeightEntry,
14};
15
16pub struct Database {
17    conn: Connection,
18}
19
20impl Database {
21    pub fn open(path: &Path) -> Result<Self> {
22        let conn = Connection::open(path)
23            .with_context(|| format!("Failed to open database: {}", path.display()))?;
24        let db = Database { conn };
25        db.migrate()?;
26        Ok(db)
27    }
28
29    pub fn open_in_memory() -> Result<Self> {
30        let conn = Connection::open_in_memory()?;
31        let db = Database { conn };
32        db.migrate()?;
33        Ok(db)
34    }
35
36    #[allow(clippy::too_many_lines)]
37    fn migrate(&self) -> Result<()> {
38        let version: i64 = self
39            .conn
40            .pragma_query_value(None, "user_version", |row| row.get(0))?;
41
42        if version < 1 {
43            self.conn.execute_batch(
44                "CREATE TABLE IF NOT EXISTS foods (
45                    id INTEGER PRIMARY KEY AUTOINCREMENT,
46                    name TEXT NOT NULL,
47                    brand TEXT,
48                    barcode TEXT UNIQUE,
49                    calories_per_100g REAL NOT NULL,
50                    protein_per_100g REAL,
51                    carbs_per_100g REAL,
52                    fat_per_100g REAL,
53                    default_serving_g REAL,
54                    source TEXT NOT NULL,
55                    created_at TEXT NOT NULL
56                );
57
58                CREATE TABLE IF NOT EXISTS meal_entries (
59                    id INTEGER PRIMARY KEY AUTOINCREMENT,
60                    date TEXT NOT NULL,
61                    meal_type TEXT NOT NULL,
62                    food_id INTEGER NOT NULL REFERENCES foods(id),
63                    serving_g REAL NOT NULL,
64                    created_at TEXT NOT NULL
65                );
66
67                CREATE TABLE IF NOT EXISTS recipes (
68                    id INTEGER PRIMARY KEY AUTOINCREMENT,
69                    food_id INTEGER NOT NULL UNIQUE REFERENCES foods(id),
70                    portions REAL NOT NULL DEFAULT 1.0,
71                    created_at TEXT NOT NULL
72                );
73
74                CREATE TABLE IF NOT EXISTS recipe_ingredients (
75                    id INTEGER PRIMARY KEY AUTOINCREMENT,
76                    recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
77                    food_id INTEGER NOT NULL REFERENCES foods(id),
78                    quantity_g REAL NOT NULL
79                );
80
81                CREATE INDEX IF NOT EXISTS idx_meal_entries_date ON meal_entries(date);
82                CREATE INDEX IF NOT EXISTS idx_foods_barcode ON foods(barcode);
83                CREATE INDEX IF NOT EXISTS idx_foods_name ON foods(name);
84                CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
85
86                CREATE TABLE IF NOT EXISTS targets (
87                    day_of_week INTEGER PRIMARY KEY CHECK (day_of_week BETWEEN 0 AND 6),
88                    calories INTEGER NOT NULL,
89                    protein_pct INTEGER,
90                    carbs_pct INTEGER,
91                    fat_pct INTEGER,
92                    updated_at TEXT NOT NULL
93                );
94
95                PRAGMA user_version = 1;",
96            )?;
97        }
98
99        if version < 2 {
100            // Add uuid and updated_at columns to existing tables
101            self.conn.execute_batch(
102                "ALTER TABLE foods ADD COLUMN uuid TEXT;
103                 ALTER TABLE foods ADD COLUMN updated_at TEXT;
104                 ALTER TABLE meal_entries ADD COLUMN uuid TEXT;
105                 ALTER TABLE meal_entries ADD COLUMN updated_at TEXT;
106                 ALTER TABLE recipes ADD COLUMN uuid TEXT;
107                 ALTER TABLE recipes ADD COLUMN updated_at TEXT;
108                 ALTER TABLE recipe_ingredients ADD COLUMN uuid TEXT;
109                 ALTER TABLE recipe_ingredients ADD COLUMN updated_at TEXT;",
110            )?;
111
112            // Generate UUIDs for existing rows
113            let now = Local::now().to_rfc3339();
114            for table in &["foods", "meal_entries", "recipes"] {
115                let ids: Vec<i64> = {
116                    let mut stmt = self.conn.prepare(&format!("SELECT id FROM {table}"))?;
117                    stmt.query_map([], |row| row.get(0))?
118                        .collect::<Result<Vec<_>, _>>()?
119                };
120                for id in ids {
121                    let uuid = Uuid::new_v4().to_string();
122                    // Use created_at as updated_at for existing rows
123                    let created_at: Option<String> = self
124                        .conn
125                        .query_row(
126                            &format!("SELECT created_at FROM {table} WHERE id = ?1"),
127                            params![id],
128                            |row| row.get(0),
129                        )
130                        .ok();
131                    let updated_at = created_at.unwrap_or_else(|| now.clone());
132                    self.conn.execute(
133                        &format!("UPDATE {table} SET uuid = ?1, updated_at = ?2 WHERE id = ?3"),
134                        params![uuid, updated_at, id],
135                    )?;
136                }
137            }
138            // recipe_ingredients don't have created_at, use now()
139            {
140                let ids: Vec<i64> = {
141                    let mut stmt = self.conn.prepare("SELECT id FROM recipe_ingredients")?;
142                    stmt.query_map([], |row| row.get(0))?
143                        .collect::<Result<Vec<_>, _>>()?
144                };
145                for id in ids {
146                    let uuid = Uuid::new_v4().to_string();
147                    self.conn.execute(
148                        "UPDATE recipe_ingredients SET uuid = ?1, updated_at = ?2 WHERE id = ?3",
149                        params![uuid, now, id],
150                    )?;
151                }
152            }
153
154            // Create unique indexes and new tables
155            self.conn.execute_batch(
156                "CREATE UNIQUE INDEX idx_foods_uuid ON foods(uuid);
157                 CREATE UNIQUE INDEX idx_meal_entries_uuid ON meal_entries(uuid);
158                 CREATE UNIQUE INDEX idx_recipes_uuid ON recipes(uuid);
159                 CREATE UNIQUE INDEX idx_recipe_ingredients_uuid ON recipe_ingredients(uuid);
160
161                 CREATE TABLE sync_tombstones (
162                     uuid TEXT NOT NULL,
163                     table_name TEXT NOT NULL,
164                     deleted_at TEXT NOT NULL
165                 );
166                 CREATE INDEX idx_tombstones_uuid ON sync_tombstones(uuid);
167
168                 CREATE TABLE config (
169                     key TEXT PRIMARY KEY,
170                     value TEXT NOT NULL
171                 );
172
173                 PRAGMA user_version = 2;",
174            )?;
175        }
176
177        if version < 3 {
178            self.conn.execute_batch(
179                "ALTER TABLE meal_entries ADD COLUMN display_unit TEXT;
180                 ALTER TABLE meal_entries ADD COLUMN display_quantity REAL;
181                 PRAGMA user_version = 3;",
182            )?;
183        }
184
185        if version < 4 {
186            // Migrate targets table from single-row (id=1) to per-day-of-week schema.
187            // Check if old schema has 'id' column (old single-row layout).
188            let has_old_schema: bool = self.conn.prepare("SELECT id FROM targets LIMIT 0").is_ok();
189
190            if has_old_schema {
191                // Preserve existing target by applying it to all 7 days.
192                self.conn.execute_batch(
193                    "CREATE TABLE targets_new (
194                        day_of_week INTEGER PRIMARY KEY CHECK (day_of_week BETWEEN 0 AND 6),
195                        calories INTEGER NOT NULL,
196                        protein_pct INTEGER,
197                        carbs_pct INTEGER,
198                        fat_pct INTEGER,
199                        updated_at TEXT NOT NULL
200                     );
201
202                     INSERT OR IGNORE INTO targets_new (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
203                     SELECT d.day, t.calories, t.protein_pct, t.carbs_pct, t.fat_pct, t.updated_at
204                     FROM targets t, (SELECT 0 AS day UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6) d
205                     WHERE t.id = 1;
206
207                     DROP TABLE targets;
208                     ALTER TABLE targets_new RENAME TO targets;",
209                )?;
210            }
211
212            self.conn.execute_batch("PRAGMA user_version = 4;")?;
213        }
214
215        if version < 5 {
216            self.conn.execute_batch(
217                "CREATE TABLE IF NOT EXISTS weight_entries (
218                    id INTEGER PRIMARY KEY,
219                    uuid TEXT NOT NULL DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
220                    date TEXT NOT NULL UNIQUE,
221                    weight_kg REAL NOT NULL,
222                    source TEXT NOT NULL DEFAULT 'manual',
223                    notes TEXT,
224                    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
225                    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
226                );
227
228                PRAGMA user_version = 5;",
229            )?;
230        }
231
232        if version < 6 {
233            self.conn.execute_batch(
234                "CREATE TABLE IF NOT EXISTS user_settings (
235                    key TEXT PRIMARY KEY NOT NULL,
236                    value TEXT NOT NULL,
237                    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
238                );
239
240                PRAGMA user_version = 6;",
241            )?;
242        }
243
244        Ok(())
245    }
246
247    // --- Row mapping helpers ---
248
249    fn food_from_row(row: &rusqlite::Row) -> rusqlite::Result<Food> {
250        Ok(Food {
251            id: row.get(0)?,
252            name: row.get(1)?,
253            brand: row.get(2)?,
254            barcode: row.get(3)?,
255            calories_per_100g: row.get(4)?,
256            protein_per_100g: row.get(5)?,
257            carbs_per_100g: row.get(6)?,
258            fat_per_100g: row.get(7)?,
259            default_serving_g: row.get(8)?,
260            source: row.get(9)?,
261            created_at: row.get(10)?,
262            uuid: row.get::<_, Option<String>>(11)?.unwrap_or_default(),
263            updated_at: row.get::<_, Option<String>>(12)?.unwrap_or_default(),
264        })
265    }
266
267    // Expects columns:
268    // 0: me.id, 1: me.uuid, 2: me.date, 3: me.meal_type, 4: me.food_id,
269    // 5: me.serving_g, 6: me.display_unit, 7: me.display_quantity,
270    // 8: me.created_at, 9: me.updated_at,
271    // 10: f.name, 11: f.brand, 12: f.calories_per_100g, 13: f.protein_per_100g,
272    // 14: f.carbs_per_100g, 15: f.fat_per_100g
273    fn meal_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<MealEntry> {
274        let serving_g: f64 = row.get(5)?;
275        let cal_100: f64 = row.get(12)?;
276        let pro_100: Option<f64> = row.get(13)?;
277        let carb_100: Option<f64> = row.get(14)?;
278        let fat_100: Option<f64> = row.get(15)?;
279        Ok(MealEntry {
280            id: row.get(0)?,
281            uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
282            date: row.get(2)?,
283            meal_type: row.get(3)?,
284            food_id: row.get(4)?,
285            serving_g,
286            display_unit: row.get(6)?,
287            display_quantity: row.get(7)?,
288            created_at: row.get(8)?,
289            updated_at: row.get::<_, Option<String>>(9)?.unwrap_or_default(),
290            food_name: Some(row.get(10)?),
291            food_brand: row.get(11)?,
292            calories: Some(cal_100 * serving_g / 100.0),
293            protein: pro_100.map(|v| v * serving_g / 100.0),
294            carbs: carb_100.map(|v| v * serving_g / 100.0),
295            fat: fat_100.map(|v| v * serving_g / 100.0),
296        })
297    }
298
299    // --- Foods ---
300
301    pub fn insert_food(&self, food: &NewFood) -> Result<Food> {
302        let now = Local::now().to_rfc3339();
303        let uuid = Uuid::new_v4().to_string();
304        self.conn.execute(
305            "INSERT INTO foods (name, brand, barcode, calories_per_100g, protein_per_100g, carbs_per_100g, fat_per_100g, default_serving_g, source, created_at, uuid, updated_at)
306             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
307            params![
308                food.name,
309                food.brand,
310                food.barcode,
311                food.calories_per_100g,
312                food.protein_per_100g,
313                food.carbs_per_100g,
314                food.fat_per_100g,
315                food.default_serving_g,
316                food.source,
317                now,
318                uuid,
319                now,
320            ],
321        )?;
322        let id = self.conn.last_insert_rowid();
323        self.get_food_by_id(id)
324    }
325
326    pub fn upsert_food_by_barcode(&self, food: &NewFood) -> Result<Food> {
327        if let Some(barcode) = &food.barcode {
328            if let Some(existing) = self.get_food_by_barcode(barcode)? {
329                return Ok(existing);
330            }
331        }
332        self.insert_food(food)
333    }
334
335    pub fn get_food_by_id(&self, id: i64) -> Result<Food> {
336        self.conn
337            .query_row(
338                "SELECT * FROM foods WHERE id = ?1",
339                params![id],
340                Self::food_from_row,
341            )
342            .context("Food not found")
343    }
344
345    pub fn get_food_by_barcode(&self, barcode: &str) -> Result<Option<Food>> {
346        let mut stmt = self
347            .conn
348            .prepare("SELECT * FROM foods WHERE barcode = ?1")?;
349        let mut rows = stmt.query(params![barcode])?;
350        if let Some(row) = rows.next()? {
351            Ok(Some(Self::food_from_row(row)?))
352        } else {
353            Ok(None)
354        }
355    }
356
357    pub fn search_foods_local(&self, query: &str) -> Result<Vec<Food>> {
358        let escaped = query
359            .replace('\\', "\\\\")
360            .replace('%', "\\%")
361            .replace('_', "\\_");
362        let pattern = format!("%{escaped}%");
363        let mut stmt = self.conn.prepare(
364            "SELECT * FROM foods WHERE name LIKE ?1 ESCAPE '\\' OR brand LIKE ?1 ESCAPE '\\' ORDER BY name LIMIT 20",
365        )?;
366        let foods = stmt
367            .query_map(params![pattern], Self::food_from_row)?
368            .collect::<Result<Vec<_>, _>>()?;
369        Ok(foods)
370    }
371
372    pub fn list_foods(&self, search: Option<&str>) -> Result<Vec<Food>> {
373        if let Some(query) = search {
374            return self.search_foods_local(query);
375        }
376        let mut stmt = self
377            .conn
378            .prepare("SELECT * FROM foods ORDER BY name LIMIT 100")?;
379        let foods = stmt
380            .query_map([], Self::food_from_row)?
381            .collect::<Result<Vec<_>, _>>()?;
382        Ok(foods)
383    }
384
385    // --- Meal Entries ---
386
387    pub fn insert_meal_entry(&self, entry: &NewMealEntry) -> Result<MealEntry> {
388        let now = Local::now().to_rfc3339();
389        let uuid = Uuid::new_v4().to_string();
390        let date_str = entry.date.format("%Y-%m-%d").to_string();
391        self.conn.execute(
392            "INSERT INTO meal_entries (date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at, uuid, updated_at)
393             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
394            params![
395                date_str,
396                entry.meal_type,
397                entry.food_id,
398                entry.serving_g,
399                entry.display_unit,
400                entry.display_quantity,
401                now,
402                uuid,
403                now,
404            ],
405        )?;
406        let id = self.conn.last_insert_rowid();
407        self.get_meal_entry(id)
408    }
409
410    pub fn get_meal_entry(&self, id: i64) -> Result<MealEntry> {
411        self.conn
412            .query_row(
413                "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
414                        me.display_unit, me.display_quantity, me.created_at, me.updated_at,
415                        f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
416                 FROM meal_entries me
417                 JOIN foods f ON me.food_id = f.id
418                 WHERE me.id = ?1",
419                params![id],
420                Self::meal_entry_from_row,
421            )
422            .context("Meal entry not found")
423    }
424
425    pub fn delete_meal_entry(&self, id: i64) -> Result<bool> {
426        let rows = self
427            .conn
428            .execute("DELETE FROM meal_entries WHERE id = ?1", params![id])?;
429        Ok(rows > 0)
430    }
431
432    pub fn update_meal_entry(&self, id: i64, update: &UpdateMealEntry) -> Result<MealEntry> {
433        // Verify existence
434        self.get_meal_entry(id)?;
435
436        let now = Local::now().to_rfc3339();
437        if let Some(serving_g) = update.serving_g {
438            self.conn.execute(
439                "UPDATE meal_entries SET serving_g = ?1, updated_at = ?2 WHERE id = ?3",
440                params![serving_g, now, id],
441            )?;
442        }
443        if let Some(ref meal_type) = update.meal_type {
444            self.conn.execute(
445                "UPDATE meal_entries SET meal_type = ?1, updated_at = ?2 WHERE id = ?3",
446                params![meal_type, now, id],
447            )?;
448        }
449        if let Some(date) = update.date {
450            let date_str = date.format("%Y-%m-%d").to_string();
451            self.conn.execute(
452                "UPDATE meal_entries SET date = ?1, updated_at = ?2 WHERE id = ?3",
453                params![date_str, now, id],
454            )?;
455        }
456        if let Some(ref display_unit) = update.display_unit {
457            self.conn.execute(
458                "UPDATE meal_entries SET display_unit = ?1, updated_at = ?2 WHERE id = ?3",
459                params![display_unit, now, id],
460            )?;
461        }
462        if let Some(ref display_quantity) = update.display_quantity {
463            self.conn.execute(
464                "UPDATE meal_entries SET display_quantity = ?1, updated_at = ?2 WHERE id = ?3",
465                params![display_quantity, now, id],
466            )?;
467        }
468
469        self.get_meal_entry(id)
470    }
471
472    pub fn get_entries_for_date(&self, date: NaiveDate) -> Result<Vec<MealEntry>> {
473        let date_str = date.format("%Y-%m-%d").to_string();
474        let mut stmt = self.conn.prepare(
475            "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
476                    me.display_unit, me.display_quantity, me.created_at, me.updated_at,
477                    f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
478             FROM meal_entries me
479             JOIN foods f ON me.food_id = f.id
480             WHERE me.date = ?1
481             ORDER BY me.id",
482        )?;
483        let entries = stmt
484            .query_map(params![date_str], Self::meal_entry_from_row)?
485            .collect::<Result<Vec<_>, _>>()?;
486        Ok(entries)
487    }
488
489    pub fn get_entries_for_date_and_meal(
490        &self,
491        date: NaiveDate,
492        meal_type: &str,
493    ) -> Result<Vec<MealEntry>> {
494        let date_str = date.format("%Y-%m-%d").to_string();
495        let mut stmt = self.conn.prepare(
496            "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
497                    me.display_unit, me.display_quantity, me.created_at, me.updated_at,
498                    f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
499             FROM meal_entries me
500             JOIN foods f ON me.food_id = f.id
501             WHERE me.date = ?1 AND me.meal_type = ?2
502             ORDER BY me.id",
503        )?;
504        let entries = stmt
505            .query_map(params![date_str, meal_type], Self::meal_entry_from_row)?
506            .collect::<Result<Vec<_>, _>>()?;
507        Ok(entries)
508    }
509
510    // --- Targets ---
511
512    pub fn set_target(
513        &self,
514        day_of_week: i64,
515        calories: i64,
516        protein_pct: Option<i64>,
517        carbs_pct: Option<i64>,
518        fat_pct: Option<i64>,
519    ) -> Result<DailyTarget> {
520        let now = Local::now().to_rfc3339();
521        self.conn.execute(
522            "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
523             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
524            params![day_of_week, calories, protein_pct, carbs_pct, fat_pct, now],
525        )?;
526        Ok(DailyTarget::from_db(
527            day_of_week,
528            calories,
529            protein_pct,
530            carbs_pct,
531            fat_pct,
532        ))
533    }
534
535    pub fn get_target(&self, day_of_week: i64) -> Result<Option<DailyTarget>> {
536        let mut stmt = self.conn.prepare(
537            "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct FROM targets WHERE day_of_week = ?1",
538        )?;
539        let mut rows = stmt.query(params![day_of_week])?;
540        if let Some(row) = rows.next()? {
541            let day: i64 = row.get(0)?;
542            let calories: i64 = row.get(1)?;
543            let protein_pct: Option<i64> = row.get(2)?;
544            let carbs_pct: Option<i64> = row.get(3)?;
545            let fat_pct: Option<i64> = row.get(4)?;
546            Ok(Some(DailyTarget::from_db(
547                day,
548                calories,
549                protein_pct,
550                carbs_pct,
551                fat_pct,
552            )))
553        } else {
554            Ok(None)
555        }
556    }
557
558    pub fn get_all_targets(&self) -> Result<Vec<DailyTarget>> {
559        let mut stmt = self.conn.prepare(
560            "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct FROM targets ORDER BY day_of_week",
561        )?;
562        let targets = stmt
563            .query_map([], |row| {
564                let day: i64 = row.get(0)?;
565                let calories: i64 = row.get(1)?;
566                let protein_pct: Option<i64> = row.get(2)?;
567                let carbs_pct: Option<i64> = row.get(3)?;
568                let fat_pct: Option<i64> = row.get(4)?;
569                Ok(DailyTarget::from_db(
570                    day,
571                    calories,
572                    protein_pct,
573                    carbs_pct,
574                    fat_pct,
575                ))
576            })?
577            .collect::<Result<Vec<_>, _>>()?;
578        Ok(targets)
579    }
580
581    pub fn clear_target(&self, day_of_week: i64) -> Result<bool> {
582        let rows = self.conn.execute(
583            "DELETE FROM targets WHERE day_of_week = ?1",
584            params![day_of_week],
585        )?;
586        Ok(rows > 0)
587    }
588
589    pub fn clear_all_targets(&self) -> Result<bool> {
590        let rows = self.conn.execute("DELETE FROM targets", [])?;
591        Ok(rows > 0)
592    }
593
594    // --- Recipes ---
595
596    pub fn create_recipe(&self, name: &str, portions: f64) -> Result<Recipe> {
597        let now = Local::now().to_rfc3339();
598        let uuid = Uuid::new_v4().to_string();
599        // Create a placeholder virtual food with zero macros — will be recomputed on add-ingredient
600        let food = self.insert_food(&NewFood {
601            name: name.to_string(),
602            brand: None,
603            barcode: None,
604            calories_per_100g: 0.0,
605            protein_per_100g: Some(0.0),
606            carbs_per_100g: Some(0.0),
607            fat_per_100g: Some(0.0),
608            default_serving_g: Some(0.0),
609            source: "recipe".to_string(),
610        })?;
611
612        self.conn.execute(
613            "INSERT INTO recipes (food_id, portions, created_at, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
614            params![food.id, portions, now, uuid, now],
615        )?;
616        let id = self.conn.last_insert_rowid();
617        Ok(Recipe {
618            id,
619            uuid,
620            food_id: food.id,
621            portions,
622            created_at: now.clone(),
623            updated_at: now,
624        })
625    }
626
627    pub fn get_recipe_by_id(&self, id: i64) -> Result<Recipe> {
628        self.conn
629            .query_row(
630                "SELECT id, uuid, food_id, portions, created_at, updated_at FROM recipes WHERE id = ?1",
631                params![id],
632                |row| {
633                    Ok(Recipe {
634                        id: row.get(0)?,
635                        uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
636                        food_id: row.get(2)?,
637                        portions: row.get(3)?,
638                        created_at: row.get(4)?,
639                        updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
640                    })
641                },
642            )
643            .context("Recipe not found")
644    }
645
646    pub fn get_recipe_by_food_name(&self, name: &str) -> Result<Recipe> {
647        self.conn
648            .query_row(
649                "SELECT r.id, r.uuid, r.food_id, r.portions, r.created_at, r.updated_at
650                 FROM recipes r JOIN foods f ON r.food_id = f.id
651                 WHERE LOWER(f.name) = LOWER(?1)",
652                params![name],
653                |row| {
654                    Ok(Recipe {
655                        id: row.get(0)?,
656                        uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
657                        food_id: row.get(2)?,
658                        portions: row.get(3)?,
659                        created_at: row.get(4)?,
660                        updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
661                    })
662                },
663            )
664            .context(format!("Recipe '{name}' not found"))
665    }
666
667    pub fn add_recipe_ingredient(
668        &self,
669        recipe_id: i64,
670        food_id: i64,
671        quantity_g: f64,
672    ) -> Result<RecipeIngredient> {
673        let now = Local::now().to_rfc3339();
674        let uuid = Uuid::new_v4().to_string();
675        self.conn.execute(
676            "INSERT INTO recipe_ingredients (recipe_id, food_id, quantity_g, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
677            params![recipe_id, food_id, quantity_g, uuid, now],
678        )?;
679        let id = self.conn.last_insert_rowid();
680
681        // Recompute virtual food
682        self.recompute_recipe_food(recipe_id)?;
683
684        Ok(RecipeIngredient {
685            id,
686            uuid,
687            recipe_id,
688            food_id,
689            quantity_g,
690            food_name: None,
691            food_brand: None,
692            calories: None,
693            protein: None,
694            carbs: None,
695            fat: None,
696        })
697    }
698
699    pub fn remove_recipe_ingredient(&self, recipe_id: i64, food_name: &str) -> Result<bool> {
700        let rows = self.conn.execute(
701            "DELETE FROM recipe_ingredients WHERE recipe_id = ?1 AND food_id IN (
702                SELECT id FROM foods WHERE LOWER(name) = LOWER(?2)
703            )",
704            params![recipe_id, food_name],
705        )?;
706        if rows > 0 {
707            self.recompute_recipe_food(recipe_id)?;
708        }
709        Ok(rows > 0)
710    }
711
712    pub fn set_recipe_portions(&self, recipe_id: i64, portions: f64) -> Result<()> {
713        let now = Local::now().to_rfc3339();
714        self.conn.execute(
715            "UPDATE recipes SET portions = ?1, updated_at = ?2 WHERE id = ?3",
716            params![portions, now, recipe_id],
717        )?;
718        self.recompute_recipe_food(recipe_id)?;
719        Ok(())
720    }
721
722    pub fn get_recipe_ingredients(&self, recipe_id: i64) -> Result<Vec<RecipeIngredient>> {
723        let mut stmt = self.conn.prepare(
724            "SELECT ri.id, ri.uuid, ri.recipe_id, ri.food_id, ri.quantity_g,
725                    f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
726             FROM recipe_ingredients ri
727             JOIN foods f ON ri.food_id = f.id
728             WHERE ri.recipe_id = ?1
729             ORDER BY ri.id",
730        )?;
731        let ingredients = stmt
732            .query_map(params![recipe_id], |row| {
733                let qty: f64 = row.get(4)?;
734                let cal_100: f64 = row.get(7)?;
735                let pro_100: Option<f64> = row.get(8)?;
736                let carb_100: Option<f64> = row.get(9)?;
737                let fat_100: Option<f64> = row.get(10)?;
738                Ok(RecipeIngredient {
739                    id: row.get(0)?,
740                    uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
741                    recipe_id: row.get(2)?,
742                    food_id: row.get(3)?,
743                    quantity_g: qty,
744                    food_name: Some(row.get(5)?),
745                    food_brand: row.get(6)?,
746                    calories: Some(cal_100 * qty / 100.0),
747                    protein: pro_100.map(|v| v * qty / 100.0),
748                    carbs: carb_100.map(|v| v * qty / 100.0),
749                    fat: fat_100.map(|v| v * qty / 100.0),
750                })
751            })?
752            .collect::<Result<Vec<_>, _>>()?;
753        Ok(ingredients)
754    }
755
756    pub fn get_recipe_detail(&self, recipe_id: i64) -> Result<RecipeDetail> {
757        let recipe = self.get_recipe_by_id(recipe_id)?;
758        let food = self.get_food_by_id(recipe.food_id)?;
759        let ingredients = self.get_recipe_ingredients(recipe_id)?;
760
761        let total_weight: f64 = ingredients.iter().map(|i| i.quantity_g).sum();
762        let total_cal: f64 = ingredients.iter().filter_map(|i| i.calories).sum();
763        let total_pro: f64 = ingredients.iter().filter_map(|i| i.protein).sum();
764        let total_carbs: f64 = ingredients.iter().filter_map(|i| i.carbs).sum();
765        let total_fat: f64 = ingredients.iter().filter_map(|i| i.fat).sum();
766
767        Ok(RecipeDetail {
768            id: recipe.id,
769            uuid: recipe.uuid,
770            food_id: recipe.food_id,
771            name: food.name,
772            portions: recipe.portions,
773            total_weight_g: total_weight,
774            per_portion_g: if recipe.portions > 0.0 {
775                total_weight / recipe.portions
776            } else {
777                0.0
778            },
779            ingredients,
780            per_portion_calories: if recipe.portions > 0.0 {
781                total_cal / recipe.portions
782            } else {
783                0.0
784            },
785            per_portion_protein: if recipe.portions > 0.0 {
786                total_pro / recipe.portions
787            } else {
788                0.0
789            },
790            per_portion_carbs: if recipe.portions > 0.0 {
791                total_carbs / recipe.portions
792            } else {
793                0.0
794            },
795            per_portion_fat: if recipe.portions > 0.0 {
796                total_fat / recipe.portions
797            } else {
798                0.0
799            },
800            calories_per_100g: food.calories_per_100g,
801            protein_per_100g: food.protein_per_100g.unwrap_or(0.0),
802            carbs_per_100g: food.carbs_per_100g.unwrap_or(0.0),
803            fat_per_100g: food.fat_per_100g.unwrap_or(0.0),
804        })
805    }
806
807    pub fn list_recipes(&self) -> Result<Vec<RecipeDetail>> {
808        let mut stmt = self.conn.prepare("SELECT id FROM recipes ORDER BY id")?;
809        let ids: Vec<i64> = stmt
810            .query_map([], |row| row.get(0))?
811            .collect::<Result<Vec<_>, _>>()?;
812        let mut details = Vec::new();
813        for id in ids {
814            details.push(self.get_recipe_detail(id)?);
815        }
816        Ok(details)
817    }
818
819    pub fn delete_recipe(&self, recipe_id: i64) -> Result<()> {
820        let recipe = self.get_recipe_by_id(recipe_id)?;
821        // Delete ingredients first (CASCADE should handle this, but be explicit)
822        self.conn.execute(
823            "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
824            params![recipe_id],
825        )?;
826        self.conn
827            .execute("DELETE FROM recipes WHERE id = ?1", params![recipe_id])?;
828        // Delete the virtual food
829        self.conn
830            .execute("DELETE FROM foods WHERE id = ?1", params![recipe.food_id])?;
831        Ok(())
832    }
833
834    fn recompute_recipe_food(&self, recipe_id: i64) -> Result<()> {
835        let recipe = self.get_recipe_by_id(recipe_id)?;
836        let ingredients = self.get_recipe_ingredients(recipe_id)?;
837
838        let total_weight: f64 = ingredients.iter().map(|i| i.quantity_g).sum();
839        let total_cal: f64 = ingredients.iter().filter_map(|i| i.calories).sum();
840        let total_pro: f64 = ingredients.iter().filter_map(|i| i.protein).sum();
841        let total_carbs: f64 = ingredients.iter().filter_map(|i| i.carbs).sum();
842        let total_fat: f64 = ingredients.iter().filter_map(|i| i.fat).sum();
843
844        let (cal_100, pro_100, carb_100, fat_100, serving_g) = if total_weight > 0.0 {
845            (
846                total_cal * 100.0 / total_weight,
847                total_pro * 100.0 / total_weight,
848                total_carbs * 100.0 / total_weight,
849                total_fat * 100.0 / total_weight,
850                total_weight / recipe.portions,
851            )
852        } else {
853            (0.0, 0.0, 0.0, 0.0, 0.0)
854        };
855
856        let now = Local::now().to_rfc3339();
857        self.conn.execute(
858            "UPDATE foods SET calories_per_100g = ?1, protein_per_100g = ?2, carbs_per_100g = ?3,
859             fat_per_100g = ?4, default_serving_g = ?5, updated_at = ?6 WHERE id = ?7",
860            params![
861                cal_100,
862                pro_100,
863                carb_100,
864                fat_100,
865                serving_g,
866                now,
867                recipe.food_id
868            ],
869        )?;
870        Ok(())
871    }
872
873    // --- Sync support ---
874
875    pub fn record_tombstone(&self, uuid: &str, table_name: &str) -> Result<()> {
876        let now = Local::now().to_rfc3339();
877        self.conn.execute(
878            "INSERT INTO sync_tombstones (uuid, table_name, deleted_at) VALUES (?1, ?2, ?3)",
879            params![uuid, table_name, now],
880        )?;
881        Ok(())
882    }
883
884    pub fn get_tombstones(&self) -> Result<Vec<SyncTombstone>> {
885        let mut stmt = self
886            .conn
887            .prepare("SELECT uuid, table_name, deleted_at FROM sync_tombstones")?;
888        let tombstones = stmt
889            .query_map([], |row| {
890                Ok(SyncTombstone {
891                    uuid: row.get(0)?,
892                    table_name: row.get(1)?,
893                    deleted_at: row.get(2)?,
894                })
895            })?
896            .collect::<Result<Vec<_>, _>>()?;
897        Ok(tombstones)
898    }
899
900    pub fn get_tombstones_since(&self, since: &str) -> Result<Vec<SyncTombstone>> {
901        let mut stmt = self.conn.prepare(
902            "SELECT uuid, table_name, deleted_at FROM sync_tombstones WHERE deleted_at > ?1",
903        )?;
904        let tombstones = stmt
905            .query_map(params![since], |row| {
906                Ok(SyncTombstone {
907                    uuid: row.get(0)?,
908                    table_name: row.get(1)?,
909                    deleted_at: row.get(2)?,
910                })
911            })?
912            .collect::<Result<Vec<_>, _>>()?;
913        Ok(tombstones)
914    }
915
916    pub fn clear_tombstones(&self) -> Result<()> {
917        self.conn.execute("DELETE FROM sync_tombstones", [])?;
918        Ok(())
919    }
920
921    // --- Delta sync ---
922
923    pub fn get_foods_since(&self, since: &str) -> Result<Vec<Food>> {
924        let mut stmt = self
925            .conn
926            .prepare("SELECT * FROM foods WHERE updated_at > ?1 ORDER BY id")?;
927        let foods = stmt
928            .query_map(params![since], Self::food_from_row)?
929            .collect::<Result<Vec<_>, _>>()?;
930        Ok(foods)
931    }
932
933    pub fn get_all_foods(&self) -> Result<Vec<Food>> {
934        let mut stmt = self.conn.prepare("SELECT * FROM foods ORDER BY id")?;
935        let foods = stmt
936            .query_map([], Self::food_from_row)?
937            .collect::<Result<Vec<_>, _>>()?;
938        Ok(foods)
939    }
940
941    pub fn get_meal_entries_since(&self, since: &str) -> Result<Vec<ExportMealEntry>> {
942        let mut stmt = self.conn.prepare(
943            "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
944                    me.display_unit, me.display_quantity, me.created_at,
945                    me.updated_at, f.uuid as food_uuid
946             FROM meal_entries me JOIN foods f ON me.food_id = f.id
947             WHERE me.updated_at > ?1
948             ORDER BY me.id",
949        )?;
950        let entries = stmt
951            .query_map(params![since], Self::export_meal_entry_from_row)?
952            .collect::<Result<Vec<_>, _>>()?;
953        Ok(entries)
954    }
955
956    pub fn get_all_meal_entries_export(&self) -> Result<Vec<ExportMealEntry>> {
957        let mut stmt = self.conn.prepare(
958            "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
959                    me.display_unit, me.display_quantity, me.created_at,
960                    me.updated_at, f.uuid as food_uuid
961             FROM meal_entries me JOIN foods f ON me.food_id = f.id
962             ORDER BY me.id",
963        )?;
964        let entries = stmt
965            .query_map([], Self::export_meal_entry_from_row)?
966            .collect::<Result<Vec<_>, _>>()?;
967        Ok(entries)
968    }
969
970    fn export_meal_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportMealEntry> {
971        Ok(ExportMealEntry {
972            id: row.get(0)?,
973            uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
974            date: row.get(2)?,
975            meal_type: row.get(3)?,
976            food_id: row.get(4)?,
977            serving_g: row.get(5)?,
978            display_unit: row.get(6)?,
979            display_quantity: row.get(7)?,
980            created_at: row.get(8)?,
981            updated_at: row.get::<_, Option<String>>(9)?.unwrap_or_default(),
982            food_uuid: row.get::<_, Option<String>>(10)?.unwrap_or_default(),
983        })
984    }
985
986    fn export_recipe_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportRecipe> {
987        Ok(ExportRecipe {
988            id: row.get(0)?,
989            uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
990            food_id: row.get(2)?,
991            portions: row.get(3)?,
992            created_at: row.get(4)?,
993            updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
994            food_uuid: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
995        })
996    }
997
998    fn export_recipe_ingredient_from_row(
999        row: &rusqlite::Row,
1000    ) -> rusqlite::Result<ExportRecipeIngredient> {
1001        Ok(ExportRecipeIngredient {
1002            id: row.get(0)?,
1003            uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
1004            recipe_id: row.get(2)?,
1005            food_id: row.get(3)?,
1006            quantity_g: row.get(4)?,
1007            recipe_uuid: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1008            food_uuid: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
1009        })
1010    }
1011
1012    fn export_target_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportTarget> {
1013        Ok(ExportTarget {
1014            day_of_week: row.get(0)?,
1015            calories: row.get(1)?,
1016            protein_pct: row.get(2)?,
1017            carbs_pct: row.get(3)?,
1018            fat_pct: row.get(4)?,
1019            updated_at: row.get(5)?,
1020        })
1021    }
1022
1023    fn export_weight_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportWeightEntry> {
1024        Ok(ExportWeightEntry {
1025            uuid: row.get(0)?,
1026            date: row.get(1)?,
1027            weight_kg: row.get(2)?,
1028            source: row.get(3)?,
1029            notes: row.get(4)?,
1030            created_at: row.get(5)?,
1031            updated_at: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
1032        })
1033    }
1034
1035    pub fn get_recipes_since(&self, since: &str) -> Result<Vec<ExportRecipe>> {
1036        let mut stmt = self.conn.prepare(
1037            "SELECT r.id, r.uuid, r.food_id, r.portions, r.created_at, r.updated_at, f.uuid as food_uuid
1038             FROM recipes r JOIN foods f ON r.food_id = f.id
1039             WHERE r.updated_at > ?1
1040             ORDER BY r.id",
1041        )?;
1042        let recipes = stmt
1043            .query_map(params![since], Self::export_recipe_from_row)?
1044            .collect::<Result<Vec<_>, _>>()?;
1045        Ok(recipes)
1046    }
1047
1048    pub fn get_all_recipes_export(&self) -> Result<Vec<ExportRecipe>> {
1049        let mut stmt = self.conn.prepare(
1050            "SELECT r.id, r.uuid, r.food_id, r.portions, r.created_at, r.updated_at, f.uuid as food_uuid
1051             FROM recipes r JOIN foods f ON r.food_id = f.id
1052             ORDER BY r.id",
1053        )?;
1054        let recipes = stmt
1055            .query_map([], Self::export_recipe_from_row)?
1056            .collect::<Result<Vec<_>, _>>()?;
1057        Ok(recipes)
1058    }
1059
1060    pub fn get_recipe_ingredients_since(&self, since: &str) -> Result<Vec<ExportRecipeIngredient>> {
1061        let mut stmt = self.conn.prepare(
1062            "SELECT ri.id, ri.uuid, ri.recipe_id, ri.food_id, ri.quantity_g,
1063                    r.uuid as recipe_uuid, f.uuid as food_uuid
1064             FROM recipe_ingredients ri
1065             JOIN recipes r ON ri.recipe_id = r.id
1066             JOIN foods f ON ri.food_id = f.id
1067             WHERE ri.updated_at > ?1
1068             ORDER BY ri.id",
1069        )?;
1070        let ingredients = stmt
1071            .query_map(params![since], Self::export_recipe_ingredient_from_row)?
1072            .collect::<Result<Vec<_>, _>>()?;
1073        Ok(ingredients)
1074    }
1075
1076    pub fn get_all_recipe_ingredients_export(&self) -> Result<Vec<ExportRecipeIngredient>> {
1077        let mut stmt = self.conn.prepare(
1078            "SELECT ri.id, ri.uuid, ri.recipe_id, ri.food_id, ri.quantity_g,
1079                    r.uuid as recipe_uuid, f.uuid as food_uuid
1080             FROM recipe_ingredients ri
1081             JOIN recipes r ON ri.recipe_id = r.id
1082             JOIN foods f ON ri.food_id = f.id
1083             ORDER BY ri.id",
1084        )?;
1085        let ingredients = stmt
1086            .query_map([], Self::export_recipe_ingredient_from_row)?
1087            .collect::<Result<Vec<_>, _>>()?;
1088        Ok(ingredients)
1089    }
1090
1091    pub fn get_targets_since(&self, since: &str) -> Result<Vec<ExportTarget>> {
1092        let mut stmt = self.conn.prepare(
1093            "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at
1094             FROM targets WHERE updated_at > ?1
1095             ORDER BY day_of_week",
1096        )?;
1097        let targets = stmt
1098            .query_map(params![since], Self::export_target_from_row)?
1099            .collect::<Result<Vec<_>, _>>()?;
1100        Ok(targets)
1101    }
1102
1103    pub fn get_all_targets_export(&self) -> Result<Vec<ExportTarget>> {
1104        let mut stmt = self.conn.prepare(
1105            "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at
1106             FROM targets ORDER BY day_of_week",
1107        )?;
1108        let targets = stmt
1109            .query_map([], Self::export_target_from_row)?
1110            .collect::<Result<Vec<_>, _>>()?;
1111        Ok(targets)
1112    }
1113
1114    pub fn get_weight_entries_since(&self, since: &str) -> Result<Vec<ExportWeightEntry>> {
1115        let mut stmt = self.conn.prepare(
1116            "SELECT uuid, date, weight_kg, source, notes, created_at, updated_at
1117             FROM weight_entries WHERE updated_at > ?1
1118             ORDER BY date",
1119        )?;
1120        let entries = stmt
1121            .query_map(params![since], Self::export_weight_entry_from_row)?
1122            .collect::<Result<Vec<_>, _>>()?;
1123        Ok(entries)
1124    }
1125
1126    pub fn get_all_weight_entries_export(&self) -> Result<Vec<ExportWeightEntry>> {
1127        let mut stmt = self.conn.prepare(
1128            "SELECT uuid, date, weight_kg, source, notes, created_at, updated_at
1129             FROM weight_entries ORDER BY date",
1130        )?;
1131        let entries = stmt
1132            .query_map([], Self::export_weight_entry_from_row)?
1133            .collect::<Result<Vec<_>, _>>()?;
1134        Ok(entries)
1135    }
1136
1137    pub fn changes_since(
1138        &self,
1139        since: Option<&str>,
1140        server_timestamp: &str,
1141    ) -> Result<SyncPayload> {
1142        let (foods, meal_entries, recipes, recipe_ingredients, targets, weight_entries, tombstones) =
1143            match since {
1144                Some(ts) => (
1145                    self.get_foods_since(ts)?,
1146                    self.get_meal_entries_since(ts)?,
1147                    self.get_recipes_since(ts)?,
1148                    self.get_recipe_ingredients_since(ts)?,
1149                    self.get_targets_since(ts)?,
1150                    self.get_weight_entries_since(ts)?,
1151                    self.get_tombstones_since(ts)?,
1152                ),
1153                None => (
1154                    self.get_all_foods()?,
1155                    self.get_all_meal_entries_export()?,
1156                    self.get_all_recipes_export()?,
1157                    self.get_all_recipe_ingredients_export()?,
1158                    self.get_all_targets_export()?,
1159                    self.get_all_weight_entries_export()?,
1160                    self.get_tombstones()?,
1161                ),
1162            };
1163        Ok(SyncPayload {
1164            foods,
1165            meal_entries,
1166            recipes,
1167            recipe_ingredients,
1168            targets,
1169            weight_entries,
1170            tombstones,
1171            server_timestamp: server_timestamp.to_string(),
1172        })
1173    }
1174
1175    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1176    pub fn apply_remote_changes(
1177        &self,
1178        foods: &[Food],
1179        meal_entries: &[ExportMealEntry],
1180        recipes: &[ExportRecipe],
1181        recipe_ingredients: &[ExportRecipeIngredient],
1182        targets: &[ExportTarget],
1183        weight_entries: &[ExportWeightEntry],
1184        tombstones: &[SyncTombstone],
1185    ) -> Result<()> {
1186        // Step 1: Merge foods — build uuid→local_id mapping
1187        let mut food_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1188        for food in foods {
1189            if food.uuid.is_empty() {
1190                continue;
1191            }
1192            if let Some(existing) = self.get_food_by_uuid(&food.uuid)? {
1193                food_uuid_to_local_id.insert(food.uuid.clone(), existing.id);
1194                if food.updated_at > existing.updated_at {
1195                    self.conn.execute(
1196                        "UPDATE foods SET name=?1, brand=?2, barcode=?3, calories_per_100g=?4,
1197                         protein_per_100g=?5, carbs_per_100g=?6, fat_per_100g=?7,
1198                         default_serving_g=?8, source=?9, updated_at=?10 WHERE uuid=?11",
1199                        params![
1200                            food.name,
1201                            food.brand,
1202                            food.barcode,
1203                            food.calories_per_100g,
1204                            food.protein_per_100g,
1205                            food.carbs_per_100g,
1206                            food.fat_per_100g,
1207                            food.default_serving_g,
1208                            food.source,
1209                            food.updated_at,
1210                            food.uuid,
1211                        ],
1212                    )?;
1213                }
1214            } else {
1215                self.conn.execute(
1216                    "INSERT INTO foods (name, brand, barcode, calories_per_100g,
1217                     protein_per_100g, carbs_per_100g, fat_per_100g,
1218                     default_serving_g, source, created_at, uuid, updated_at)
1219                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1220                    params![
1221                        food.name,
1222                        food.brand,
1223                        food.barcode,
1224                        food.calories_per_100g,
1225                        food.protein_per_100g,
1226                        food.carbs_per_100g,
1227                        food.fat_per_100g,
1228                        food.default_serving_g,
1229                        food.source,
1230                        food.created_at,
1231                        food.uuid,
1232                        food.updated_at,
1233                    ],
1234                )?;
1235                let new_id = self.conn.last_insert_rowid();
1236                food_uuid_to_local_id.insert(food.uuid.clone(), new_id);
1237            }
1238        }
1239
1240        // Step 2: Merge meal entries
1241        for entry in meal_entries {
1242            if entry.uuid.is_empty() {
1243                continue;
1244            }
1245            let local_food_id = if entry.food_uuid.is_empty() {
1246                None
1247            } else {
1248                food_uuid_to_local_id
1249                    .get(&entry.food_uuid)
1250                    .copied()
1251                    .or_else(|| {
1252                        self.get_food_by_uuid(&entry.food_uuid)
1253                            .ok()
1254                            .flatten()
1255                            .map(|f| f.id)
1256                    })
1257            };
1258            let Some(food_id) = local_food_id else {
1259                continue;
1260            };
1261
1262            if let Some(existing_id) = self.get_meal_entry_by_uuid(&entry.uuid)? {
1263                let existing_updated: String = self.conn.query_row(
1264                    "SELECT COALESCE(updated_at, '') FROM meal_entries WHERE id = ?1",
1265                    params![existing_id],
1266                    |row| row.get(0),
1267                )?;
1268                if entry.updated_at > existing_updated {
1269                    self.conn.execute(
1270                        "UPDATE meal_entries SET date=?1, meal_type=?2, food_id=?3, serving_g=?4, display_unit=?5, display_quantity=?6, updated_at=?7 WHERE id=?8",
1271                        params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.updated_at, existing_id],
1272                    )?;
1273                }
1274            } else {
1275                self.conn.execute(
1276                    "INSERT INTO meal_entries (date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at, uuid, updated_at)
1277                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1278                    params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.created_at, entry.uuid, entry.updated_at],
1279                )?;
1280            }
1281        }
1282
1283        // Step 3: Merge recipes — build recipe_uuid→local_id mapping
1284        let mut recipe_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1285        for recipe in recipes {
1286            if recipe.uuid.is_empty() {
1287                continue;
1288            }
1289            let local_food_id = if recipe.food_uuid.is_empty() {
1290                None
1291            } else {
1292                food_uuid_to_local_id
1293                    .get(&recipe.food_uuid)
1294                    .copied()
1295                    .or_else(|| {
1296                        self.get_food_by_uuid(&recipe.food_uuid)
1297                            .ok()
1298                            .flatten()
1299                            .map(|f| f.id)
1300                    })
1301            };
1302            let Some(food_id) = local_food_id else {
1303                continue;
1304            };
1305
1306            if let Some(existing) = self.get_recipe_by_uuid(&recipe.uuid)? {
1307                recipe_uuid_to_local_id.insert(recipe.uuid.clone(), existing.id);
1308                if recipe.updated_at > existing.updated_at {
1309                    self.conn.execute(
1310                        "UPDATE recipes SET food_id=?1, portions=?2, updated_at=?3 WHERE id=?4",
1311                        params![food_id, recipe.portions, recipe.updated_at, existing.id],
1312                    )?;
1313                }
1314            } else {
1315                self.conn.execute(
1316                    "INSERT INTO recipes (food_id, portions, created_at, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1317                    params![food_id, recipe.portions, recipe.created_at, recipe.uuid, recipe.updated_at],
1318                )?;
1319                let new_id = self.conn.last_insert_rowid();
1320                recipe_uuid_to_local_id.insert(recipe.uuid.clone(), new_id);
1321            }
1322        }
1323
1324        // Step 4: Merge recipe ingredients
1325        let mut recipes_to_recompute: std::collections::HashSet<i64> =
1326            std::collections::HashSet::new();
1327        for ing in recipe_ingredients {
1328            if ing.uuid.is_empty() {
1329                continue;
1330            }
1331            let local_recipe_id = if ing.recipe_uuid.is_empty() {
1332                None
1333            } else {
1334                recipe_uuid_to_local_id
1335                    .get(&ing.recipe_uuid)
1336                    .copied()
1337                    .or_else(|| {
1338                        self.get_recipe_by_uuid(&ing.recipe_uuid)
1339                            .ok()
1340                            .flatten()
1341                            .map(|r| r.id)
1342                    })
1343            };
1344            let local_food_id = if ing.food_uuid.is_empty() {
1345                None
1346            } else {
1347                food_uuid_to_local_id
1348                    .get(&ing.food_uuid)
1349                    .copied()
1350                    .or_else(|| {
1351                        self.get_food_by_uuid(&ing.food_uuid)
1352                            .ok()
1353                            .flatten()
1354                            .map(|f| f.id)
1355                    })
1356            };
1357            let (Some(recipe_id), Some(food_id)) = (local_recipe_id, local_food_id) else {
1358                continue;
1359            };
1360
1361            if let Some(existing_id) = self.get_recipe_ingredient_by_uuid(&ing.uuid)? {
1362                self.conn.execute(
1363                    "UPDATE recipe_ingredients SET recipe_id=?1, food_id=?2, quantity_g=?3 WHERE id=?4",
1364                    params![recipe_id, food_id, ing.quantity_g, existing_id],
1365                )?;
1366            } else {
1367                let now = Local::now().to_rfc3339();
1368                self.conn.execute(
1369                    "INSERT INTO recipe_ingredients (recipe_id, food_id, quantity_g, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1370                    params![recipe_id, food_id, ing.quantity_g, ing.uuid, now],
1371                )?;
1372            }
1373            recipes_to_recompute.insert(recipe_id);
1374        }
1375
1376        // Recompute virtual foods for affected recipes
1377        for recipe_id in &recipes_to_recompute {
1378            self.recompute_recipe_food(*recipe_id)?;
1379        }
1380
1381        // Step 5: Merge targets
1382        for incoming_target in targets {
1383            let local_updated: Option<String> = self
1384                .conn
1385                .query_row(
1386                    "SELECT updated_at FROM targets WHERE day_of_week = ?1",
1387                    params![incoming_target.day_of_week],
1388                    |row| row.get(0),
1389                )
1390                .ok();
1391            let should_update = match (&incoming_target.updated_at, &local_updated) {
1392                (Some(incoming), Some(local)) => incoming > local,
1393                (Some(_), None) | (None, _) => true,
1394            };
1395            if should_update {
1396                let updated_at = incoming_target
1397                    .updated_at
1398                    .clone()
1399                    .unwrap_or_else(|| Local::now().to_rfc3339());
1400                self.conn.execute(
1401                    "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
1402                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1403                    params![
1404                        incoming_target.day_of_week,
1405                        incoming_target.calories,
1406                        incoming_target.protein_pct,
1407                        incoming_target.carbs_pct,
1408                        incoming_target.fat_pct,
1409                        updated_at,
1410                    ],
1411                )?;
1412            }
1413        }
1414
1415        // Step 6: Process tombstones
1416        let mut dummy_recompute = std::collections::HashSet::new();
1417        for tombstone in tombstones {
1418            self.apply_tombstone(tombstone, &mut dummy_recompute)?;
1419            // Store tombstone for propagation
1420            let exists: i64 = self
1421                .conn
1422                .query_row(
1423                    "SELECT COUNT(*) FROM sync_tombstones WHERE uuid = ?1 AND table_name = ?2",
1424                    params![tombstone.uuid, tombstone.table_name],
1425                    |row| row.get(0),
1426                )
1427                .unwrap_or(0);
1428            if exists == 0 {
1429                self.conn.execute(
1430                    "INSERT INTO sync_tombstones (uuid, table_name, deleted_at) VALUES (?1, ?2, ?3)",
1431                    params![tombstone.uuid, tombstone.table_name, tombstone.deleted_at],
1432                )?;
1433            }
1434        }
1435
1436        // Step 7: Merge weight entries (LWW by date — newer updated_at wins)
1437        for entry in weight_entries {
1438            if entry.uuid.is_empty() {
1439                continue;
1440            }
1441            let existing: Option<(String, String)> = self
1442                .conn
1443                .query_row(
1444                    "SELECT uuid, updated_at FROM weight_entries WHERE date = ?1",
1445                    params![entry.date],
1446                    |row| Ok((row.get(0)?, row.get(1)?)),
1447                )
1448                .ok();
1449            if let Some((_existing_uuid, existing_updated)) = existing {
1450                if entry.updated_at > existing_updated {
1451                    self.conn.execute(
1452                        "UPDATE weight_entries SET uuid=?1, weight_kg=?2, source=?3, notes=?4, updated_at=?5 WHERE date=?6",
1453                        params![entry.uuid, entry.weight_kg, entry.source, entry.notes, entry.updated_at, entry.date],
1454                    )?;
1455                }
1456            } else {
1457                self.conn.execute(
1458                    "INSERT INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
1459                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1460                    params![entry.uuid, entry.date, entry.weight_kg, entry.source, entry.notes, entry.created_at, entry.updated_at],
1461                )?;
1462            }
1463        }
1464
1465        Ok(())
1466    }
1467
1468    pub fn get_or_create_device_id(&self) -> Result<String> {
1469        let mut stmt = self
1470            .conn
1471            .prepare("SELECT value FROM config WHERE key = 'device_id'")?;
1472        let mut rows = stmt.query([])?;
1473        if let Some(row) = rows.next()? {
1474            return Ok(row.get(0)?);
1475        }
1476        drop(rows);
1477        drop(stmt);
1478
1479        let device_id = Uuid::new_v4().to_string();
1480        self.conn.execute(
1481            "INSERT INTO config (key, value) VALUES ('device_id', ?1)",
1482            params![device_id],
1483        )?;
1484        Ok(device_id)
1485    }
1486
1487    pub fn get_food_by_uuid(&self, uuid: &str) -> Result<Option<Food>> {
1488        let mut stmt = self.conn.prepare("SELECT * FROM foods WHERE uuid = ?1")?;
1489        let mut rows = stmt.query(params![uuid])?;
1490        if let Some(row) = rows.next()? {
1491            Ok(Some(Self::food_from_row(row)?))
1492        } else {
1493            Ok(None)
1494        }
1495    }
1496
1497    fn get_meal_entry_by_uuid(&self, uuid: &str) -> Result<Option<i64>> {
1498        let mut stmt = self
1499            .conn
1500            .prepare("SELECT id FROM meal_entries WHERE uuid = ?1")?;
1501        let mut rows = stmt.query(params![uuid])?;
1502        if let Some(row) = rows.next()? {
1503            Ok(Some(row.get(0)?))
1504        } else {
1505            Ok(None)
1506        }
1507    }
1508
1509    fn get_recipe_by_uuid(&self, uuid: &str) -> Result<Option<Recipe>> {
1510        let mut stmt = self.conn.prepare(
1511            "SELECT id, uuid, food_id, portions, created_at, updated_at FROM recipes WHERE uuid = ?1",
1512        )?;
1513        let mut rows = stmt.query(params![uuid])?;
1514        if let Some(row) = rows.next()? {
1515            Ok(Some(Recipe {
1516                id: row.get(0)?,
1517                uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
1518                food_id: row.get(2)?,
1519                portions: row.get(3)?,
1520                created_at: row.get(4)?,
1521                updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1522            }))
1523        } else {
1524            Ok(None)
1525        }
1526    }
1527
1528    fn get_recipe_ingredient_by_uuid(&self, uuid: &str) -> Result<Option<i64>> {
1529        let mut stmt = self
1530            .conn
1531            .prepare("SELECT id FROM recipe_ingredients WHERE uuid = ?1")?;
1532        let mut rows = stmt.query(params![uuid])?;
1533        if let Some(row) = rows.next()? {
1534            Ok(Some(row.get(0)?))
1535        } else {
1536            Ok(None)
1537        }
1538    }
1539
1540    pub fn get_meal_entry_uuid(&self, id: i64) -> Result<Option<String>> {
1541        self.conn
1542            .query_row(
1543                "SELECT uuid FROM meal_entries WHERE id = ?1",
1544                params![id],
1545                |row| row.get(0),
1546            )
1547            .context("Meal entry not found")
1548            .map(Some)
1549    }
1550
1551    pub fn get_recipe_uuid(&self, id: i64) -> Result<Option<String>> {
1552        self.conn
1553            .query_row(
1554                "SELECT uuid FROM recipes WHERE id = ?1",
1555                params![id],
1556                |row| row.get(0),
1557            )
1558            .context("Recipe not found")
1559            .map(Some)
1560    }
1561
1562    pub fn get_recipe_ingredient_uuids(&self, recipe_id: i64) -> Result<Vec<String>> {
1563        let mut stmt = self
1564            .conn
1565            .prepare("SELECT uuid FROM recipe_ingredients WHERE recipe_id = ?1")?;
1566        let uuids = stmt
1567            .query_map(params![recipe_id], |row| row.get(0))?
1568            .collect::<Result<Vec<String>, _>>()?;
1569        Ok(uuids)
1570    }
1571
1572    // --- Export / Import ---
1573
1574    #[allow(clippy::too_many_lines)]
1575    pub fn export_all(&self) -> Result<ExportData> {
1576        let device_id = self.get_or_create_device_id()?;
1577        let foods = self.get_all_foods()?;
1578        let meal_entries = self.get_all_meal_entries_export()?;
1579        let recipes = self.get_all_recipes_export()?;
1580        let recipe_ingredients = self.get_all_recipe_ingredients_export()?;
1581        let targets = self.get_all_targets_export()?;
1582        let weight_entries = self.get_all_weight_entries_export()?;
1583        let tombstones = self.get_tombstones()?;
1584
1585        let exported_at = Local::now().to_rfc3339();
1586        Ok(ExportData {
1587            version: 3,
1588            exported_at,
1589            device_id: Some(device_id),
1590            foods,
1591            meal_entries,
1592            recipes,
1593            recipe_ingredients,
1594            target: None,
1595            targets,
1596            weight_entries,
1597            tombstones: Some(tombstones),
1598        })
1599    }
1600
1601    pub fn import_all(&self, data: &ExportData) -> Result<ImportSummary> {
1602        if data.version >= 2 {
1603            self.merge_import(data)
1604        } else {
1605            self.import_v1(data)
1606        }
1607    }
1608
1609    fn import_v1(&self, data: &ExportData) -> Result<ImportSummary> {
1610        let foods_imported = self.import_foods(&data.foods)?;
1611        let meal_entries_imported = self.import_meal_entries(&data.meal_entries)?;
1612        let (recipes_imported, recipe_ingredients_imported) =
1613            self.import_recipes(&data.recipes, &data.recipe_ingredients)?;
1614        let targets_imported = self.import_targets(data)?;
1615        let weight_entries_imported = self.import_weight_entries(&data.weight_entries)?;
1616
1617        Ok(ImportSummary {
1618            foods_imported,
1619            meal_entries_imported,
1620            recipes_imported,
1621            recipe_ingredients_imported,
1622            targets_imported,
1623            weight_entries_imported,
1624            tombstones_processed: 0,
1625        })
1626    }
1627
1628    #[allow(clippy::cast_possible_wrap)]
1629    fn import_foods(&self, foods: &[Food]) -> Result<i64> {
1630        let mut count: i64 = 0;
1631        for food in foods {
1632            let exists = self
1633                .conn
1634                .query_row(
1635                    "SELECT COUNT(*) FROM foods WHERE id = ?1",
1636                    params![food.id],
1637                    |row| row.get::<_, i64>(0),
1638                )
1639                .unwrap_or(0);
1640            if exists > 0 {
1641                self.conn.execute(
1642                    "UPDATE foods SET name=?1, brand=?2, barcode=?3, calories_per_100g=?4,
1643                     protein_per_100g=?5, carbs_per_100g=?6, fat_per_100g=?7,
1644                     default_serving_g=?8, source=?9 WHERE id=?10",
1645                    params![
1646                        food.name,
1647                        food.brand,
1648                        food.barcode,
1649                        food.calories_per_100g,
1650                        food.protein_per_100g,
1651                        food.carbs_per_100g,
1652                        food.fat_per_100g,
1653                        food.default_serving_g,
1654                        food.source,
1655                        food.id,
1656                    ],
1657                )?;
1658            } else {
1659                self.insert_food_for_import(food)?;
1660            }
1661            count += 1;
1662        }
1663        Ok(count)
1664    }
1665
1666    fn insert_food_for_import(&self, food: &Food) -> Result<()> {
1667        if let Some(barcode) = &food.barcode {
1668            let barcode_exists = self
1669                .conn
1670                .query_row(
1671                    "SELECT COUNT(*) FROM foods WHERE barcode = ?1",
1672                    params![barcode],
1673                    |row| row.get::<_, i64>(0),
1674                )
1675                .unwrap_or(0);
1676            if barcode_exists > 0 {
1677                return Ok(());
1678            }
1679        }
1680        self.conn.execute(
1681            "INSERT INTO foods (id, name, brand, barcode, calories_per_100g,
1682             protein_per_100g, carbs_per_100g, fat_per_100g,
1683             default_serving_g, source, created_at)
1684             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1685            params![
1686                food.id,
1687                food.name,
1688                food.brand,
1689                food.barcode,
1690                food.calories_per_100g,
1691                food.protein_per_100g,
1692                food.carbs_per_100g,
1693                food.fat_per_100g,
1694                food.default_serving_g,
1695                food.source,
1696                food.created_at,
1697            ],
1698        )?;
1699        Ok(())
1700    }
1701
1702    #[allow(clippy::cast_possible_wrap)]
1703    fn import_meal_entries(&self, entries: &[ExportMealEntry]) -> Result<i64> {
1704        let mut count: i64 = 0;
1705        for entry in entries {
1706            self.conn.execute(
1707                "INSERT OR REPLACE INTO meal_entries (id, date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at)
1708                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1709                params![
1710                    entry.id,
1711                    entry.date,
1712                    entry.meal_type,
1713                    entry.food_id,
1714                    entry.serving_g,
1715                    entry.display_unit,
1716                    entry.display_quantity,
1717                    entry.created_at,
1718                ],
1719            )?;
1720            count += 1;
1721        }
1722        Ok(count)
1723    }
1724
1725    #[allow(clippy::cast_possible_wrap)]
1726    fn import_recipes(
1727        &self,
1728        recipes: &[ExportRecipe],
1729        ingredients: &[ExportRecipeIngredient],
1730    ) -> Result<(i64, i64)> {
1731        let mut recipe_count: i64 = 0;
1732        let mut ingredient_count: i64 = 0;
1733
1734        for recipe in recipes {
1735            self.conn.execute(
1736                "INSERT OR REPLACE INTO recipes (id, food_id, portions, created_at)
1737                 VALUES (?1, ?2, ?3, ?4)",
1738                params![
1739                    recipe.id,
1740                    recipe.food_id,
1741                    recipe.portions,
1742                    recipe.created_at
1743                ],
1744            )?;
1745            self.conn.execute(
1746                "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
1747                params![recipe.id],
1748            )?;
1749            recipe_count += 1;
1750        }
1751
1752        for ing in ingredients {
1753            self.conn.execute(
1754                "INSERT INTO recipe_ingredients (id, recipe_id, food_id, quantity_g)
1755                 VALUES (?1, ?2, ?3, ?4)",
1756                params![ing.id, ing.recipe_id, ing.food_id, ing.quantity_g],
1757            )?;
1758            ingredient_count += 1;
1759        }
1760
1761        Ok((recipe_count, ingredient_count))
1762    }
1763
1764    #[allow(clippy::cast_possible_wrap)]
1765    fn import_targets(&self, data: &ExportData) -> Result<i64> {
1766        let now = Local::now().to_rfc3339();
1767
1768        if !data.targets.is_empty() {
1769            let mut count: i64 = 0;
1770            for target in &data.targets {
1771                self.conn.execute(
1772                    "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
1773                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1774                    params![
1775                        target.day_of_week,
1776                        target.calories,
1777                        target.protein_pct,
1778                        target.carbs_pct,
1779                        target.fat_pct,
1780                        now,
1781                    ],
1782                )?;
1783                count += 1;
1784            }
1785            Ok(count)
1786        } else if let Some(legacy) = &data.target {
1787            // Legacy single target — apply to all 7 days
1788            for day in 0..7_i64 {
1789                self.conn.execute(
1790                    "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
1791                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1792                    params![
1793                        day,
1794                        legacy.calories,
1795                        legacy.protein_pct,
1796                        legacy.carbs_pct,
1797                        legacy.fat_pct,
1798                        now,
1799                    ],
1800                )?;
1801            }
1802            Ok(7)
1803        } else {
1804            Ok(0)
1805        }
1806    }
1807
1808    #[allow(clippy::cast_possible_wrap)]
1809    fn import_weight_entries(&self, entries: &[ExportWeightEntry]) -> Result<i64> {
1810        let mut count: i64 = 0;
1811        for entry in entries {
1812            self.conn.execute(
1813                "INSERT OR REPLACE INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
1814                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1815                params![
1816                    entry.uuid,
1817                    entry.date,
1818                    entry.weight_kg,
1819                    entry.source,
1820                    entry.notes,
1821                    entry.created_at,
1822                    entry.updated_at,
1823                ],
1824            )?;
1825            count += 1;
1826        }
1827        Ok(count)
1828    }
1829
1830    #[allow(clippy::cast_possible_wrap, clippy::too_many_lines)]
1831    fn merge_import(&self, data: &ExportData) -> Result<ImportSummary> {
1832        let mut foods_imported: i64 = 0;
1833        let mut meal_entries_imported: i64 = 0;
1834        let mut recipes_imported: i64 = 0;
1835        let mut recipe_ingredients_imported: i64 = 0;
1836        let mut tombstones_processed: i64 = 0;
1837
1838        // Step 1: Merge foods — build uuid→local_id mapping
1839        let mut food_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1840        for food in &data.foods {
1841            if food.uuid.is_empty() {
1842                continue;
1843            }
1844            if let Some(existing) = self.get_food_by_uuid(&food.uuid)? {
1845                food_uuid_to_local_id.insert(food.uuid.clone(), existing.id);
1846                if food.updated_at > existing.updated_at {
1847                    self.conn.execute(
1848                        "UPDATE foods SET name=?1, brand=?2, barcode=?3, calories_per_100g=?4,
1849                         protein_per_100g=?5, carbs_per_100g=?6, fat_per_100g=?7,
1850                         default_serving_g=?8, source=?9, updated_at=?10 WHERE uuid=?11",
1851                        params![
1852                            food.name,
1853                            food.brand,
1854                            food.barcode,
1855                            food.calories_per_100g,
1856                            food.protein_per_100g,
1857                            food.carbs_per_100g,
1858                            food.fat_per_100g,
1859                            food.default_serving_g,
1860                            food.source,
1861                            food.updated_at,
1862                            food.uuid,
1863                        ],
1864                    )?;
1865                    foods_imported += 1;
1866                }
1867            } else {
1868                self.conn.execute(
1869                    "INSERT INTO foods (name, brand, barcode, calories_per_100g,
1870                     protein_per_100g, carbs_per_100g, fat_per_100g,
1871                     default_serving_g, source, created_at, uuid, updated_at)
1872                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1873                    params![
1874                        food.name,
1875                        food.brand,
1876                        food.barcode,
1877                        food.calories_per_100g,
1878                        food.protein_per_100g,
1879                        food.carbs_per_100g,
1880                        food.fat_per_100g,
1881                        food.default_serving_g,
1882                        food.source,
1883                        food.created_at,
1884                        food.uuid,
1885                        food.updated_at,
1886                    ],
1887                )?;
1888                let new_id = self.conn.last_insert_rowid();
1889                food_uuid_to_local_id.insert(food.uuid.clone(), new_id);
1890                foods_imported += 1;
1891            }
1892        }
1893
1894        // Step 2: Merge meal entries
1895        for entry in &data.meal_entries {
1896            if entry.uuid.is_empty() {
1897                continue;
1898            }
1899            let local_food_id = if entry.food_uuid.is_empty() {
1900                None
1901            } else {
1902                food_uuid_to_local_id.get(&entry.food_uuid).copied()
1903            };
1904            let Some(food_id) = local_food_id else {
1905                continue;
1906            };
1907
1908            if let Some(existing_id) = self.get_meal_entry_by_uuid(&entry.uuid)? {
1909                let existing_updated: String = self.conn.query_row(
1910                    "SELECT COALESCE(updated_at, '') FROM meal_entries WHERE id = ?1",
1911                    params![existing_id],
1912                    |row| row.get(0),
1913                )?;
1914                if entry.updated_at > existing_updated {
1915                    self.conn.execute(
1916                        "UPDATE meal_entries SET date=?1, meal_type=?2, food_id=?3, serving_g=?4, display_unit=?5, display_quantity=?6, updated_at=?7 WHERE id=?8",
1917                        params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.updated_at, existing_id],
1918                    )?;
1919                    meal_entries_imported += 1;
1920                }
1921            } else {
1922                self.conn.execute(
1923                    "INSERT INTO meal_entries (date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at, uuid, updated_at)
1924                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1925                    params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.created_at, entry.uuid, entry.updated_at],
1926                )?;
1927                meal_entries_imported += 1;
1928            }
1929        }
1930
1931        // Step 3: Merge recipes — build recipe_uuid→local_id mapping
1932        let mut recipe_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1933        for recipe in &data.recipes {
1934            if recipe.uuid.is_empty() {
1935                continue;
1936            }
1937            let local_food_id = if recipe.food_uuid.is_empty() {
1938                None
1939            } else {
1940                food_uuid_to_local_id.get(&recipe.food_uuid).copied()
1941            };
1942            let Some(food_id) = local_food_id else {
1943                continue;
1944            };
1945
1946            if let Some(existing) = self.get_recipe_by_uuid(&recipe.uuid)? {
1947                recipe_uuid_to_local_id.insert(recipe.uuid.clone(), existing.id);
1948                if recipe.updated_at > existing.updated_at {
1949                    self.conn.execute(
1950                        "UPDATE recipes SET food_id=?1, portions=?2, updated_at=?3 WHERE id=?4",
1951                        params![food_id, recipe.portions, recipe.updated_at, existing.id],
1952                    )?;
1953                    recipes_imported += 1;
1954                }
1955            } else {
1956                self.conn.execute(
1957                    "INSERT INTO recipes (food_id, portions, created_at, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1958                    params![food_id, recipe.portions, recipe.created_at, recipe.uuid, recipe.updated_at],
1959                )?;
1960                let new_id = self.conn.last_insert_rowid();
1961                recipe_uuid_to_local_id.insert(recipe.uuid.clone(), new_id);
1962                recipes_imported += 1;
1963            }
1964        }
1965
1966        // Step 4: Merge recipe ingredients
1967        let mut recipes_to_recompute: std::collections::HashSet<i64> =
1968            std::collections::HashSet::new();
1969        for ing in &data.recipe_ingredients {
1970            if ing.uuid.is_empty() {
1971                continue;
1972            }
1973            let local_recipe_id = if ing.recipe_uuid.is_empty() {
1974                None
1975            } else {
1976                recipe_uuid_to_local_id.get(&ing.recipe_uuid).copied()
1977            };
1978            let local_food_id = if ing.food_uuid.is_empty() {
1979                None
1980            } else {
1981                food_uuid_to_local_id.get(&ing.food_uuid).copied()
1982            };
1983            let (Some(recipe_id), Some(food_id)) = (local_recipe_id, local_food_id) else {
1984                continue;
1985            };
1986
1987            if let Some(existing_id) = self.get_recipe_ingredient_by_uuid(&ing.uuid)? {
1988                self.conn.execute(
1989                    "UPDATE recipe_ingredients SET recipe_id=?1, food_id=?2, quantity_g=?3 WHERE id=?4",
1990                    params![recipe_id, food_id, ing.quantity_g, existing_id],
1991                )?;
1992                recipe_ingredients_imported += 1;
1993            } else {
1994                let now = Local::now().to_rfc3339();
1995                self.conn.execute(
1996                    "INSERT INTO recipe_ingredients (recipe_id, food_id, quantity_g, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1997                    params![recipe_id, food_id, ing.quantity_g, ing.uuid, now],
1998                )?;
1999                recipe_ingredients_imported += 1;
2000            }
2001            recipes_to_recompute.insert(recipe_id);
2002        }
2003
2004        // Recompute virtual foods for affected recipes
2005        for recipe_id in &recipes_to_recompute {
2006            self.recompute_recipe_food(*recipe_id)?;
2007        }
2008
2009        // Step 5: Merge targets
2010        let mut targets_imported: i64 = 0;
2011        // Determine the list of targets to merge
2012        let targets_to_merge: Vec<ExportTarget> = if !data.targets.is_empty() {
2013            data.targets.clone()
2014        } else if let Some(legacy) = &data.target {
2015            // Legacy single target — expand to all 7 days
2016            (0..7_i64)
2017                .map(|day| ExportTarget {
2018                    day_of_week: day,
2019                    calories: legacy.calories,
2020                    protein_pct: legacy.protein_pct,
2021                    carbs_pct: legacy.carbs_pct,
2022                    fat_pct: legacy.fat_pct,
2023                    updated_at: legacy.updated_at.clone(),
2024                })
2025                .collect()
2026        } else {
2027            Vec::new()
2028        };
2029        for incoming_target in &targets_to_merge {
2030            let local_updated: Option<String> = self
2031                .conn
2032                .query_row(
2033                    "SELECT updated_at FROM targets WHERE day_of_week = ?1",
2034                    params![incoming_target.day_of_week],
2035                    |row| row.get(0),
2036                )
2037                .ok();
2038            let should_update = match (&incoming_target.updated_at, &local_updated) {
2039                (Some(incoming), Some(local)) => incoming > local,
2040                (Some(_), None) | (None, _) => true,
2041            };
2042            if should_update {
2043                let updated_at = incoming_target
2044                    .updated_at
2045                    .clone()
2046                    .unwrap_or_else(|| Local::now().to_rfc3339());
2047                self.conn.execute(
2048                    "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
2049                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2050                    params![
2051                        incoming_target.day_of_week,
2052                        incoming_target.calories,
2053                        incoming_target.protein_pct,
2054                        incoming_target.carbs_pct,
2055                        incoming_target.fat_pct,
2056                        updated_at,
2057                    ],
2058                )?;
2059                targets_imported += 1;
2060            }
2061        }
2062
2063        // Step 6: Process tombstones — delete local records if older than tombstone
2064        if let Some(tombstones) = &data.tombstones {
2065            for tombstone in tombstones {
2066                let deleted = self.apply_tombstone(tombstone, &mut recipes_to_recompute)?;
2067                if deleted {
2068                    tombstones_processed += 1;
2069                }
2070            }
2071        }
2072
2073        // Step 7: Store incoming tombstones locally for propagation
2074        if let Some(tombstones) = &data.tombstones {
2075            for tombstone in tombstones {
2076                let exists: i64 = self
2077                    .conn
2078                    .query_row(
2079                        "SELECT COUNT(*) FROM sync_tombstones WHERE uuid = ?1 AND table_name = ?2",
2080                        params![tombstone.uuid, tombstone.table_name],
2081                        |row| row.get(0),
2082                    )
2083                    .unwrap_or(0);
2084                if exists == 0 {
2085                    self.conn.execute(
2086                        "INSERT INTO sync_tombstones (uuid, table_name, deleted_at) VALUES (?1, ?2, ?3)",
2087                        params![tombstone.uuid, tombstone.table_name, tombstone.deleted_at],
2088                    )?;
2089                }
2090            }
2091        }
2092
2093        // Recompute any recipes affected by tombstone ingredient deletions
2094        for recipe_id in recipes_to_recompute {
2095            if self.get_recipe_by_id(recipe_id).is_ok() {
2096                self.recompute_recipe_food(recipe_id)?;
2097            }
2098        }
2099
2100        // Step 8: Merge weight entries (LWW by date — newer updated_at wins)
2101        let mut weight_entries_imported: i64 = 0;
2102        for entry in &data.weight_entries {
2103            if entry.uuid.is_empty() {
2104                continue;
2105            }
2106            let existing: Option<(String, String)> = self
2107                .conn
2108                .query_row(
2109                    "SELECT uuid, updated_at FROM weight_entries WHERE date = ?1",
2110                    params![entry.date],
2111                    |row| Ok((row.get(0)?, row.get(1)?)),
2112                )
2113                .ok();
2114            if let Some((_existing_uuid, existing_updated)) = existing {
2115                if entry.updated_at > existing_updated {
2116                    self.conn.execute(
2117                        "UPDATE weight_entries SET uuid=?1, weight_kg=?2, source=?3, notes=?4, updated_at=?5 WHERE date=?6",
2118                        params![entry.uuid, entry.weight_kg, entry.source, entry.notes, entry.updated_at, entry.date],
2119                    )?;
2120                    weight_entries_imported += 1;
2121                }
2122            } else {
2123                self.conn.execute(
2124                    "INSERT INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
2125                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2126                    params![entry.uuid, entry.date, entry.weight_kg, entry.source, entry.notes, entry.created_at, entry.updated_at],
2127                )?;
2128                weight_entries_imported += 1;
2129            }
2130        }
2131
2132        Ok(ImportSummary {
2133            foods_imported,
2134            meal_entries_imported,
2135            recipes_imported,
2136            recipe_ingredients_imported,
2137            targets_imported,
2138            weight_entries_imported,
2139            tombstones_processed,
2140        })
2141    }
2142
2143    fn apply_tombstone(
2144        &self,
2145        tombstone: &SyncTombstone,
2146        recipes_to_recompute: &mut std::collections::HashSet<i64>,
2147    ) -> Result<bool> {
2148        match tombstone.table_name.as_str() {
2149            "foods" => {
2150                if let Some(food) = self.get_food_by_uuid(&tombstone.uuid)? {
2151                    if food.updated_at < tombstone.deleted_at {
2152                        self.conn.execute(
2153                            "DELETE FROM foods WHERE uuid = ?1",
2154                            params![tombstone.uuid],
2155                        )?;
2156                        return Ok(true);
2157                    }
2158                }
2159                Ok(false)
2160            }
2161            "meal_entries" => {
2162                let local: Option<(i64, String)> = self
2163                    .conn
2164                    .query_row(
2165                        "SELECT id, COALESCE(updated_at, '') FROM meal_entries WHERE uuid = ?1",
2166                        params![tombstone.uuid],
2167                        |row| Ok((row.get(0)?, row.get(1)?)),
2168                    )
2169                    .ok();
2170                if let Some((id, updated_at)) = local {
2171                    if updated_at < tombstone.deleted_at {
2172                        self.conn
2173                            .execute("DELETE FROM meal_entries WHERE id = ?1", params![id])?;
2174                        return Ok(true);
2175                    }
2176                }
2177                Ok(false)
2178            }
2179            "recipes" => {
2180                if let Some(recipe) = self.get_recipe_by_uuid(&tombstone.uuid)? {
2181                    if recipe.updated_at < tombstone.deleted_at {
2182                        self.conn.execute(
2183                            "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
2184                            params![recipe.id],
2185                        )?;
2186                        self.conn
2187                            .execute("DELETE FROM recipes WHERE id = ?1", params![recipe.id])?;
2188                        self.conn
2189                            .execute("DELETE FROM foods WHERE id = ?1", params![recipe.food_id])?;
2190                        return Ok(true);
2191                    }
2192                }
2193                Ok(false)
2194            }
2195            "recipe_ingredients" => {
2196                let local: Option<(i64, String, i64)> = self
2197                    .conn
2198                    .query_row(
2199                        "SELECT id, COALESCE(updated_at, ''), recipe_id FROM recipe_ingredients WHERE uuid = ?1",
2200                        params![tombstone.uuid],
2201                        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2202                    )
2203                    .ok();
2204                if let Some((id, updated_at, recipe_id)) = local {
2205                    if updated_at < tombstone.deleted_at {
2206                        self.conn
2207                            .execute("DELETE FROM recipe_ingredients WHERE id = ?1", params![id])?;
2208                        recipes_to_recompute.insert(recipe_id);
2209                        return Ok(true);
2210                    }
2211                }
2212                Ok(false)
2213            }
2214            _ => Ok(false),
2215        }
2216    }
2217
2218    // --- Weight Entries ---
2219
2220    pub fn upsert_weight(&self, entry: &NewWeightEntry) -> Result<WeightEntry> {
2221        let now = Local::now().to_rfc3339();
2222        let uuid = Uuid::new_v4().to_string();
2223        let date_str = entry.date.format("%Y-%m-%d").to_string();
2224        self.conn.execute(
2225            "INSERT INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
2226             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
2227             ON CONFLICT(date) DO UPDATE SET
2228                weight_kg = excluded.weight_kg,
2229                source = excluded.source,
2230                notes = excluded.notes,
2231                updated_at = excluded.updated_at",
2232            params![uuid, date_str, entry.weight_kg, entry.source, entry.notes, now, now],
2233        )?;
2234        self.get_weight(entry.date)?
2235            .context("Weight entry not found after upsert")
2236    }
2237
2238    pub fn get_weight(&self, date: NaiveDate) -> Result<Option<WeightEntry>> {
2239        let date_str = date.format("%Y-%m-%d").to_string();
2240        let mut stmt = self.conn.prepare(
2241            "SELECT id, uuid, date, weight_kg, source, notes, created_at, updated_at
2242             FROM weight_entries WHERE date = ?1",
2243        )?;
2244        let mut rows = stmt.query(params![date_str])?;
2245        if let Some(row) = rows.next()? {
2246            Ok(Some(Self::weight_entry_from_row(row)?))
2247        } else {
2248            Ok(None)
2249        }
2250    }
2251
2252    pub fn get_weight_history(&self, days: Option<i64>) -> Result<Vec<WeightEntry>> {
2253        let query = match days {
2254            Some(n) => format!(
2255                "SELECT id, uuid, date, weight_kg, source, notes, created_at, updated_at
2256                 FROM weight_entries ORDER BY date DESC LIMIT {n}"
2257            ),
2258            None => "SELECT id, uuid, date, weight_kg, source, notes, created_at, updated_at
2259                     FROM weight_entries ORDER BY date DESC"
2260                .to_string(),
2261        };
2262        let mut stmt = self.conn.prepare(&query)?;
2263        let entries = stmt
2264            .query_map([], Self::weight_entry_from_row)?
2265            .collect::<Result<Vec<_>, _>>()?;
2266        Ok(entries)
2267    }
2268
2269    pub fn delete_weight(&self, id: i64) -> Result<()> {
2270        let rows = self
2271            .conn
2272            .execute("DELETE FROM weight_entries WHERE id = ?1", params![id])?;
2273        if rows == 0 {
2274            anyhow::bail!("Weight entry not found");
2275        }
2276        Ok(())
2277    }
2278
2279    fn weight_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<WeightEntry> {
2280        let date_str: String = row.get(2)?;
2281        let date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
2282            .unwrap_or_else(|_| NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid date"));
2283        Ok(WeightEntry {
2284            id: row.get(0)?,
2285            uuid: row.get(1)?,
2286            date,
2287            weight_kg: row.get(3)?,
2288            source: row.get(4)?,
2289            notes: row.get(5)?,
2290            created_at: row.get(6)?,
2291            updated_at: row.get(7)?,
2292        })
2293    }
2294
2295    // --- UX Queries ---
2296
2297    pub fn get_recently_logged_foods(&self, limit: i64) -> Result<Vec<RecentFood>> {
2298        let mut stmt = self.conn.prepare(
2299            "SELECT f.id, f.name, f.brand, f.barcode, f.calories_per_100g,
2300                    f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g,
2301                    f.default_serving_g, f.source, f.created_at, f.uuid, f.updated_at,
2302                    latest.last_serving_g, latest.last_meal_type,
2303                    counts.log_count, counts.last_date
2304             FROM foods f
2305             JOIN (
2306                 SELECT food_id, COUNT(*) as log_count, MAX(date) as last_date
2307                 FROM meal_entries
2308                 GROUP BY food_id
2309             ) counts ON f.id = counts.food_id
2310             JOIN (
2311                 SELECT me.food_id, me.serving_g as last_serving_g, me.meal_type as last_meal_type
2312                 FROM meal_entries me
2313                 INNER JOIN (
2314                     SELECT food_id, MAX(id) as max_id
2315                     FROM meal_entries
2316                     WHERE (food_id, date) IN (
2317                         SELECT food_id, MAX(date) FROM meal_entries GROUP BY food_id
2318                     )
2319                     GROUP BY food_id
2320                 ) latest_ids ON me.id = latest_ids.max_id
2321             ) latest ON f.id = latest.food_id
2322             ORDER BY counts.last_date DESC, counts.log_count DESC
2323             LIMIT ?1",
2324        )?;
2325        let foods = stmt
2326            .query_map(params![limit], |row| {
2327                let food = Self::food_from_row(row)?;
2328                Ok(RecentFood {
2329                    food,
2330                    last_serving_g: row.get(13)?,
2331                    last_meal_type: row.get(14)?,
2332                    log_count: row.get(15)?,
2333                    last_logged: row.get(16)?,
2334                })
2335            })?
2336            .collect::<Result<Vec<_>, _>>()?;
2337        Ok(foods)
2338    }
2339
2340    pub fn get_logging_streak(&self, today: NaiveDate) -> Result<i64> {
2341        // Get distinct dates with meal entries, ordered DESC
2342        let mut stmt = self
2343            .conn
2344            .prepare("SELECT DISTINCT date FROM meal_entries ORDER BY date DESC")?;
2345        let dates: Vec<String> = stmt
2346            .query_map([], |row| row.get(0))?
2347            .collect::<Result<Vec<_>, _>>()?;
2348
2349        if dates.is_empty() {
2350            return Ok(0);
2351        }
2352
2353        let today_str = today.format("%Y-%m-%d").to_string();
2354        let yesterday = today - chrono::Duration::days(1);
2355        let yesterday_str = yesterday.format("%Y-%m-%d").to_string();
2356
2357        // Determine starting point: today or yesterday
2358        let start_date = if dates.first().is_some_and(|d| d == &today_str) {
2359            today
2360        } else if dates.first().is_some_and(|d| d == &yesterday_str) {
2361            yesterday
2362        } else {
2363            return Ok(0);
2364        };
2365
2366        let mut streak: i64 = 0;
2367        for date_str in &dates {
2368            let expected = (start_date - chrono::Duration::days(streak))
2369                .format("%Y-%m-%d")
2370                .to_string();
2371            if date_str == &expected {
2372                streak += 1;
2373            } else {
2374                break;
2375            }
2376        }
2377
2378        Ok(streak)
2379    }
2380
2381    #[allow(clippy::cast_precision_loss)]
2382    pub fn get_calorie_average(&self, days: i64) -> Result<f64> {
2383        let today = Local::now().date_naive();
2384        let start_date = today - chrono::Duration::days(days - 1);
2385        let start_str = start_date.format("%Y-%m-%d").to_string();
2386        let end_str = today.format("%Y-%m-%d").to_string();
2387
2388        let result: Option<f64> = self.conn.query_row(
2389            "SELECT AVG(daily_total) FROM (
2390                SELECT SUM(f.calories_per_100g * me.serving_g / 100.0) as daily_total
2391                FROM meal_entries me
2392                JOIN foods f ON me.food_id = f.id
2393                WHERE me.date >= ?1 AND me.date <= ?2
2394                GROUP BY me.date
2395            )",
2396            params![start_str, end_str],
2397            |row| row.get(0),
2398        )?;
2399
2400        Ok(result.unwrap_or(0.0))
2401    }
2402
2403    // --- User Settings ---
2404
2405    pub fn set_setting(&self, key: &str, value: &str) -> Result<()> {
2406        let now = Local::now().to_rfc3339();
2407        self.conn.execute(
2408            "INSERT INTO user_settings (key, value, updated_at)
2409             VALUES (?1, ?2, ?3)
2410             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
2411            params![key, value, now],
2412        )?;
2413        Ok(())
2414    }
2415
2416    pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
2417        let mut stmt = self
2418            .conn
2419            .prepare("SELECT value FROM user_settings WHERE key = ?1")?;
2420        let mut rows = stmt.query(params![key])?;
2421        if let Some(row) = rows.next()? {
2422            Ok(Some(row.get(0)?))
2423        } else {
2424            Ok(None)
2425        }
2426    }
2427
2428    pub fn delete_setting(&self, key: &str) -> Result<bool> {
2429        let rows = self
2430            .conn
2431            .execute("DELETE FROM user_settings WHERE key = ?1", params![key])?;
2432        Ok(rows > 0)
2433    }
2434
2435    pub fn build_daily_summary(&self, date: NaiveDate) -> Result<DailySummary> {
2436        let entries = self.get_entries_for_date(date)?;
2437        let mut meals: Vec<MealGroup> = Vec::new();
2438
2439        for meal_type in MEAL_TYPES {
2440            let meal_entries: Vec<MealEntry> = entries
2441                .iter()
2442                .filter(|e| e.meal_type == *meal_type)
2443                .cloned()
2444                .collect();
2445
2446            if meal_entries.is_empty() {
2447                continue;
2448            }
2449
2450            let subtotal_calories: f64 = meal_entries.iter().filter_map(|e| e.calories).sum();
2451            let subtotal_protein: f64 = meal_entries.iter().filter_map(|e| e.protein).sum();
2452            let subtotal_carbs: f64 = meal_entries.iter().filter_map(|e| e.carbs).sum();
2453            let subtotal_fat: f64 = meal_entries.iter().filter_map(|e| e.fat).sum();
2454
2455            meals.push(MealGroup {
2456                meal_type: meal_type.to_string(),
2457                entries: meal_entries,
2458                subtotal_calories,
2459                subtotal_protein,
2460                subtotal_carbs,
2461                subtotal_fat,
2462            });
2463        }
2464
2465        let total_calories: f64 = meals.iter().map(|m| m.subtotal_calories).sum();
2466        let total_protein: f64 = meals.iter().map(|m| m.subtotal_protein).sum();
2467        let total_carbs: f64 = meals.iter().map(|m| m.subtotal_carbs).sum();
2468        let total_fat: f64 = meals.iter().map(|m| m.subtotal_fat).sum();
2469
2470        let day_of_week = i64::from(date.weekday().num_days_from_monday());
2471        let target = self.get_target(day_of_week)?;
2472
2473        Ok(DailySummary {
2474            date: date.format("%Y-%m-%d").to_string(),
2475            meals,
2476            total_calories,
2477            total_protein,
2478            total_carbs,
2479            total_fat,
2480            target,
2481        })
2482    }
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487    use super::*;
2488    use crate::models::{NewFood, NewMealEntry, UpdateMealEntry};
2489
2490    fn sample_food() -> NewFood {
2491        NewFood {
2492            name: "Chicken Breast".to_string(),
2493            brand: Some("Acme".to_string()),
2494            barcode: Some("1234567890".to_string()),
2495            calories_per_100g: 165.0,
2496            protein_per_100g: Some(31.0),
2497            carbs_per_100g: Some(0.0),
2498            fat_per_100g: Some(3.6),
2499            default_serving_g: Some(150.0),
2500            source: "manual".to_string(),
2501        }
2502    }
2503
2504    #[test]
2505    fn test_insert_and_get_food() {
2506        let db = Database::open_in_memory().unwrap();
2507        let food = db.insert_food(&sample_food()).unwrap();
2508
2509        assert_eq!(food.name, "Chicken Breast");
2510        assert_eq!(food.brand.as_deref(), Some("Acme"));
2511        assert_eq!(food.barcode.as_deref(), Some("1234567890"));
2512        assert_eq!(food.calories_per_100g, 165.0);
2513        assert_eq!(food.protein_per_100g, Some(31.0));
2514        assert_eq!(food.source, "manual");
2515
2516        let fetched = db.get_food_by_id(food.id).unwrap();
2517        assert_eq!(fetched.id, food.id);
2518        assert_eq!(fetched.name, "Chicken Breast");
2519    }
2520
2521    #[test]
2522    fn test_upsert_food_by_barcode() {
2523        let db = Database::open_in_memory().unwrap();
2524        let food1 = db.upsert_food_by_barcode(&sample_food()).unwrap();
2525        let food2 = db.upsert_food_by_barcode(&sample_food()).unwrap();
2526
2527        // Should return the same food (dedup by barcode)
2528        assert_eq!(food1.id, food2.id);
2529    }
2530
2531    #[test]
2532    fn test_search_foods_local() {
2533        let db = Database::open_in_memory().unwrap();
2534        db.insert_food(&sample_food()).unwrap();
2535        db.insert_food(&NewFood {
2536            name: "Brown Rice".to_string(),
2537            brand: None,
2538            barcode: None,
2539            calories_per_100g: 112.0,
2540            protein_per_100g: Some(2.6),
2541            carbs_per_100g: Some(23.5),
2542            fat_per_100g: Some(0.9),
2543            default_serving_g: None,
2544            source: "manual".to_string(),
2545        })
2546        .unwrap();
2547
2548        let results = db.search_foods_local("chicken").unwrap();
2549        assert_eq!(results.len(), 1);
2550        assert_eq!(results[0].name, "Chicken Breast");
2551
2552        let results = db.search_foods_local("rice").unwrap();
2553        assert_eq!(results.len(), 1);
2554        assert_eq!(results[0].name, "Brown Rice");
2555
2556        let results = db.search_foods_local("pizza").unwrap();
2557        assert!(results.is_empty());
2558    }
2559
2560    #[test]
2561    fn test_list_foods() {
2562        let db = Database::open_in_memory().unwrap();
2563        db.insert_food(&sample_food()).unwrap();
2564        db.insert_food(&NewFood {
2565            name: "Brown Rice".to_string(),
2566            brand: None,
2567            barcode: None,
2568            calories_per_100g: 112.0,
2569            protein_per_100g: None,
2570            carbs_per_100g: None,
2571            fat_per_100g: None,
2572            default_serving_g: None,
2573            source: "manual".to_string(),
2574        })
2575        .unwrap();
2576
2577        // List all
2578        let all = db.list_foods(None).unwrap();
2579        assert_eq!(all.len(), 2);
2580
2581        // List with filter
2582        let filtered = db.list_foods(Some("rice")).unwrap();
2583        assert_eq!(filtered.len(), 1);
2584        assert_eq!(filtered[0].name, "Brown Rice");
2585    }
2586
2587    #[test]
2588    fn test_insert_and_get_meal_entry() {
2589        let db = Database::open_in_memory().unwrap();
2590        let food = db.insert_food(&sample_food()).unwrap();
2591
2592        let entry = db
2593            .insert_meal_entry(&NewMealEntry {
2594                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2595                meal_type: "lunch".to_string(),
2596                food_id: food.id,
2597                serving_g: 200.0,
2598                display_unit: None,
2599                display_quantity: None,
2600            })
2601            .unwrap();
2602
2603        assert_eq!(entry.meal_type, "lunch");
2604        assert_eq!(entry.serving_g, 200.0);
2605        assert_eq!(entry.food_name.as_deref(), Some("Chicken Breast"));
2606        // 165 cal/100g * 200g / 100 = 330 kcal
2607        let cal = entry.calories.unwrap();
2608        assert!((cal - 330.0).abs() < 0.01);
2609        // 31 protein/100g * 200/100 = 62
2610        let pro = entry.protein.unwrap();
2611        assert!((pro - 62.0).abs() < 0.01);
2612    }
2613
2614    #[test]
2615    fn test_delete_meal_entry() {
2616        let db = Database::open_in_memory().unwrap();
2617        let food = db.insert_food(&sample_food()).unwrap();
2618        let entry = db
2619            .insert_meal_entry(&NewMealEntry {
2620                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2621                meal_type: "lunch".to_string(),
2622                food_id: food.id,
2623                serving_g: 100.0,
2624                display_unit: None,
2625                display_quantity: None,
2626            })
2627            .unwrap();
2628
2629        assert!(db.delete_meal_entry(entry.id).unwrap());
2630        // Deleting again should return false
2631        assert!(!db.delete_meal_entry(entry.id).unwrap());
2632    }
2633
2634    #[test]
2635    fn test_get_entries_for_date() {
2636        let db = Database::open_in_memory().unwrap();
2637        let food = db.insert_food(&sample_food()).unwrap();
2638        let date1 = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2639        let date2 = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
2640
2641        db.insert_meal_entry(&NewMealEntry {
2642            date: date1,
2643            meal_type: "breakfast".to_string(),
2644            food_id: food.id,
2645            serving_g: 100.0,
2646            display_unit: None,
2647            display_quantity: None,
2648        })
2649        .unwrap();
2650        db.insert_meal_entry(&NewMealEntry {
2651            date: date2,
2652            meal_type: "lunch".to_string(),
2653            food_id: food.id,
2654            serving_g: 150.0,
2655            display_unit: None,
2656            display_quantity: None,
2657        })
2658        .unwrap();
2659
2660        let entries = db.get_entries_for_date(date1).unwrap();
2661        assert_eq!(entries.len(), 1);
2662        assert_eq!(entries[0].meal_type, "breakfast");
2663
2664        let entries = db.get_entries_for_date(date2).unwrap();
2665        assert_eq!(entries.len(), 1);
2666        assert_eq!(entries[0].meal_type, "lunch");
2667    }
2668
2669    #[test]
2670    fn test_get_entries_for_date_and_meal() {
2671        let db = Database::open_in_memory().unwrap();
2672        let food = db.insert_food(&sample_food()).unwrap();
2673        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2674
2675        db.insert_meal_entry(&NewMealEntry {
2676            date,
2677            meal_type: "breakfast".to_string(),
2678            food_id: food.id,
2679            serving_g: 100.0,
2680            display_unit: None,
2681            display_quantity: None,
2682        })
2683        .unwrap();
2684        db.insert_meal_entry(&NewMealEntry {
2685            date,
2686            meal_type: "lunch".to_string(),
2687            food_id: food.id,
2688            serving_g: 200.0,
2689            display_unit: None,
2690            display_quantity: None,
2691        })
2692        .unwrap();
2693
2694        let breakfast = db.get_entries_for_date_and_meal(date, "breakfast").unwrap();
2695        assert_eq!(breakfast.len(), 1);
2696        assert_eq!(breakfast[0].serving_g, 100.0);
2697
2698        let lunch = db.get_entries_for_date_and_meal(date, "lunch").unwrap();
2699        assert_eq!(lunch.len(), 1);
2700        assert_eq!(lunch[0].serving_g, 200.0);
2701
2702        let dinner = db.get_entries_for_date_and_meal(date, "dinner").unwrap();
2703        assert!(dinner.is_empty());
2704    }
2705
2706    #[test]
2707    fn test_build_daily_summary() {
2708        let db = Database::open_in_memory().unwrap();
2709        let food = db.insert_food(&sample_food()).unwrap();
2710        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2711
2712        // Breakfast: 100g -> 165 kcal
2713        db.insert_meal_entry(&NewMealEntry {
2714            date,
2715            meal_type: "breakfast".to_string(),
2716            food_id: food.id,
2717            serving_g: 100.0,
2718            display_unit: None,
2719            display_quantity: None,
2720        })
2721        .unwrap();
2722        // Lunch: 200g -> 330 kcal
2723        db.insert_meal_entry(&NewMealEntry {
2724            date,
2725            meal_type: "lunch".to_string(),
2726            food_id: food.id,
2727            serving_g: 200.0,
2728            display_unit: None,
2729            display_quantity: None,
2730        })
2731        .unwrap();
2732
2733        let summary = db.build_daily_summary(date).unwrap();
2734        assert_eq!(summary.meals.len(), 2);
2735        assert_eq!(summary.meals[0].meal_type, "breakfast");
2736        assert_eq!(summary.meals[1].meal_type, "lunch");
2737        assert!((summary.meals[0].subtotal_calories - 165.0).abs() < 0.01);
2738        assert!((summary.meals[1].subtotal_calories - 330.0).abs() < 0.01);
2739        assert!((summary.total_calories - 495.0).abs() < 0.01);
2740        assert!((summary.total_protein - 93.0).abs() < 0.01); // 31*1 + 31*2
2741    }
2742
2743    #[test]
2744    fn test_build_daily_summary_empty() {
2745        let db = Database::open_in_memory().unwrap();
2746        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2747
2748        let summary = db.build_daily_summary(date).unwrap();
2749        assert!(summary.meals.is_empty());
2750        assert_eq!(summary.total_calories, 0.0);
2751        assert_eq!(summary.total_protein, 0.0);
2752        assert_eq!(summary.total_carbs, 0.0);
2753        assert_eq!(summary.total_fat, 0.0);
2754        assert!(summary.target.is_none());
2755    }
2756
2757    #[test]
2758    fn test_set_and_get_target() {
2759        let db = Database::open_in_memory().unwrap();
2760
2761        // No target initially
2762        assert!(db.get_target(0).unwrap().is_none());
2763
2764        // Set target with macros for Monday (0)
2765        let target = db
2766            .set_target(0, 1800, Some(40), Some(30), Some(30))
2767            .unwrap();
2768        assert_eq!(target.day_of_week, 0);
2769        assert_eq!(target.calories, 1800);
2770        assert_eq!(target.protein_pct, Some(40));
2771        assert!((target.protein_g.unwrap() - 180.0).abs() < 0.01);
2772        assert!((target.carbs_g.unwrap() - 135.0).abs() < 0.01);
2773        assert!((target.fat_g.unwrap() - 60.0).abs() < 0.01);
2774
2775        // Read it back
2776        let fetched = db.get_target(0).unwrap().unwrap();
2777        assert_eq!(fetched.calories, 1800);
2778        assert_eq!(fetched.protein_pct, Some(40));
2779
2780        // Different day should have no target
2781        assert!(db.get_target(1).unwrap().is_none());
2782
2783        // Set a different day
2784        let sat = db.set_target(5, 2200, None, None, None).unwrap();
2785        assert_eq!(sat.day_of_week, 5);
2786        assert_eq!(sat.calories, 2200);
2787
2788        // Update Monday (replace)
2789        let updated = db.set_target(0, 2000, None, None, None).unwrap();
2790        assert_eq!(updated.calories, 2000);
2791        assert!(updated.protein_pct.is_none());
2792
2793        // Monday should be updated
2794        let fetched = db.get_target(0).unwrap().unwrap();
2795        assert_eq!(fetched.calories, 2000);
2796
2797        // get_all_targets should return both
2798        let all = db.get_all_targets().unwrap();
2799        assert_eq!(all.len(), 2);
2800        assert_eq!(all[0].day_of_week, 0);
2801        assert_eq!(all[1].day_of_week, 5);
2802    }
2803
2804    #[test]
2805    fn test_clear_target() {
2806        let db = Database::open_in_memory().unwrap();
2807
2808        // Clear when nothing set
2809        assert!(!db.clear_target(0).unwrap());
2810
2811        // Set targets for Mon and Tue
2812        db.set_target(0, 1800, None, None, None).unwrap();
2813        db.set_target(1, 1900, None, None, None).unwrap();
2814        assert!(db.get_target(0).unwrap().is_some());
2815        assert!(db.get_target(1).unwrap().is_some());
2816
2817        // Clear Monday only
2818        assert!(db.clear_target(0).unwrap());
2819        assert!(db.get_target(0).unwrap().is_none());
2820        assert!(db.get_target(1).unwrap().is_some());
2821
2822        // Clear all
2823        db.set_target(0, 1800, None, None, None).unwrap();
2824        assert!(db.clear_all_targets().unwrap());
2825        assert!(db.get_all_targets().unwrap().is_empty());
2826
2827        // Clear all when empty
2828        assert!(!db.clear_all_targets().unwrap());
2829    }
2830
2831    #[test]
2832    fn test_summary_includes_target() {
2833        let db = Database::open_in_memory().unwrap();
2834        // 2024-06-15 is a Saturday = day_of_week 5
2835        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2836
2837        // No target
2838        let summary = db.build_daily_summary(date).unwrap();
2839        assert!(summary.target.is_none());
2840
2841        // Set target for Saturday (5)
2842        db.set_target(5, 1800, Some(40), Some(30), Some(30))
2843            .unwrap();
2844        let summary = db.build_daily_summary(date).unwrap();
2845        let target = summary.target.unwrap();
2846        assert_eq!(target.calories, 1800);
2847        assert_eq!(target.day_of_week, 5);
2848        assert!((target.protein_g.unwrap() - 180.0).abs() < 0.01);
2849
2850        // Monday target should NOT appear for Saturday
2851        db.set_target(0, 2500, None, None, None).unwrap();
2852        let summary = db.build_daily_summary(date).unwrap();
2853        let target = summary.target.unwrap();
2854        assert_eq!(target.calories, 1800); // still Saturday's target
2855    }
2856
2857    #[test]
2858    fn test_update_meal_entry_serving() {
2859        let db = Database::open_in_memory().unwrap();
2860        let food = db.insert_food(&sample_food()).unwrap();
2861        let entry = db
2862            .insert_meal_entry(&NewMealEntry {
2863                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2864                meal_type: "lunch".to_string(),
2865                food_id: food.id,
2866                serving_g: 100.0,
2867                display_unit: None,
2868                display_quantity: None,
2869            })
2870            .unwrap();
2871
2872        let updated = db
2873            .update_meal_entry(
2874                entry.id,
2875                &UpdateMealEntry {
2876                    serving_g: Some(250.0),
2877                    meal_type: None,
2878                    date: None,
2879                    display_unit: None,
2880                    display_quantity: None,
2881                },
2882            )
2883            .unwrap();
2884
2885        assert_eq!(updated.serving_g, 250.0);
2886        assert_eq!(updated.meal_type, "lunch");
2887        // 165 * 250 / 100 = 412.5
2888        assert!((updated.calories.unwrap() - 412.5).abs() < 0.01);
2889    }
2890
2891    #[test]
2892    fn test_update_meal_entry_meal_type() {
2893        let db = Database::open_in_memory().unwrap();
2894        let food = db.insert_food(&sample_food()).unwrap();
2895        let entry = db
2896            .insert_meal_entry(&NewMealEntry {
2897                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2898                meal_type: "lunch".to_string(),
2899                food_id: food.id,
2900                serving_g: 100.0,
2901                display_unit: None,
2902                display_quantity: None,
2903            })
2904            .unwrap();
2905
2906        let updated = db
2907            .update_meal_entry(
2908                entry.id,
2909                &UpdateMealEntry {
2910                    serving_g: None,
2911                    meal_type: Some("dinner".to_string()),
2912                    date: None,
2913                    display_unit: None,
2914                    display_quantity: None,
2915                },
2916            )
2917            .unwrap();
2918
2919        assert_eq!(updated.meal_type, "dinner");
2920        assert_eq!(updated.serving_g, 100.0);
2921    }
2922
2923    #[test]
2924    fn test_update_meal_entry_not_found() {
2925        let db = Database::open_in_memory().unwrap();
2926        let result = db.update_meal_entry(
2927            999,
2928            &UpdateMealEntry {
2929                serving_g: Some(100.0),
2930                meal_type: None,
2931                date: None,
2932                display_unit: None,
2933                display_quantity: None,
2934            },
2935        );
2936        assert!(result.is_err());
2937    }
2938
2939    #[test]
2940    fn test_update_meal_entry_noop() {
2941        let db = Database::open_in_memory().unwrap();
2942        let food = db.insert_food(&sample_food()).unwrap();
2943        let entry = db
2944            .insert_meal_entry(&NewMealEntry {
2945                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2946                meal_type: "lunch".to_string(),
2947                food_id: food.id,
2948                serving_g: 100.0,
2949                display_unit: None,
2950                display_quantity: None,
2951            })
2952            .unwrap();
2953
2954        let updated = db
2955            .update_meal_entry(
2956                entry.id,
2957                &UpdateMealEntry {
2958                    serving_g: None,
2959                    meal_type: None,
2960                    date: None,
2961                    display_unit: None,
2962                    display_quantity: None,
2963                },
2964            )
2965            .unwrap();
2966
2967        assert_eq!(updated.serving_g, 100.0);
2968        assert_eq!(updated.meal_type, "lunch");
2969    }
2970
2971    // --- Recipe tests ---
2972
2973    fn sample_ingredient_rice() -> NewFood {
2974        NewFood {
2975            name: "Brown Rice".to_string(),
2976            brand: None,
2977            barcode: None,
2978            calories_per_100g: 112.0,
2979            protein_per_100g: Some(2.6),
2980            carbs_per_100g: Some(23.5),
2981            fat_per_100g: Some(0.9),
2982            default_serving_g: None,
2983            source: "manual".to_string(),
2984        }
2985    }
2986
2987    #[test]
2988    fn test_create_recipe() {
2989        let db = Database::open_in_memory().unwrap();
2990        let recipe = db.create_recipe("Chicken and Rice", 4.0).unwrap();
2991        assert_eq!(recipe.portions, 4.0);
2992
2993        // Virtual food should exist
2994        let food = db.get_food_by_id(recipe.food_id).unwrap();
2995        assert_eq!(food.name, "Chicken and Rice");
2996        assert_eq!(food.source, "recipe");
2997    }
2998
2999    #[test]
3000    fn test_recipe_add_ingredient_recomputes() {
3001        let db = Database::open_in_memory().unwrap();
3002        let chicken = db.insert_food(&sample_food()).unwrap();
3003        let rice = db.insert_food(&sample_ingredient_rice()).unwrap();
3004        let recipe = db.create_recipe("Chicken and Rice", 2.0).unwrap();
3005
3006        // Add 200g chicken: 165 cal/100g -> 330 cal total
3007        db.add_recipe_ingredient(recipe.id, chicken.id, 200.0)
3008            .unwrap();
3009        // Add 300g rice: 112 cal/100g -> 336 cal total
3010        db.add_recipe_ingredient(recipe.id, rice.id, 300.0).unwrap();
3011
3012        let detail = db.get_recipe_detail(recipe.id).unwrap();
3013        assert_eq!(detail.ingredients.len(), 2);
3014        assert!((detail.total_weight_g - 500.0).abs() < 0.01);
3015        assert!((detail.per_portion_g - 250.0).abs() < 0.01);
3016
3017        // Total cal = 330 + 336 = 666
3018        let expected_total_cal = 330.0 + 336.0;
3019        let expected_per_portion_cal = expected_total_cal / 2.0;
3020        assert!((detail.per_portion_calories - expected_per_portion_cal).abs() < 0.01);
3021
3022        // Virtual food per-100g should be recomputed
3023        let food = db.get_food_by_id(recipe.food_id).unwrap();
3024        let expected_cal_100 = expected_total_cal * 100.0 / 500.0;
3025        assert!((food.calories_per_100g - expected_cal_100).abs() < 0.01);
3026        // default_serving_g = total_weight / portions = 500/2 = 250
3027        assert!((food.default_serving_g.unwrap() - 250.0).abs() < 0.01);
3028    }
3029
3030    #[test]
3031    fn test_recipe_set_portions() {
3032        let db = Database::open_in_memory().unwrap();
3033        let chicken = db.insert_food(&sample_food()).unwrap();
3034        let recipe = db.create_recipe("Just Chicken", 2.0).unwrap();
3035        db.add_recipe_ingredient(recipe.id, chicken.id, 400.0)
3036            .unwrap();
3037
3038        // Change to 4 portions
3039        db.set_recipe_portions(recipe.id, 4.0).unwrap();
3040
3041        let food = db.get_food_by_id(recipe.food_id).unwrap();
3042        // default_serving_g = 400 / 4 = 100
3043        assert!((food.default_serving_g.unwrap() - 100.0).abs() < 0.01);
3044        // cal per 100g stays the same
3045        assert!((food.calories_per_100g - 165.0).abs() < 0.01);
3046    }
3047
3048    #[test]
3049    fn test_recipe_remove_ingredient() {
3050        let db = Database::open_in_memory().unwrap();
3051        let chicken = db.insert_food(&sample_food()).unwrap();
3052        let rice = db.insert_food(&sample_ingredient_rice()).unwrap();
3053        let recipe = db.create_recipe("Mixed", 1.0).unwrap();
3054        db.add_recipe_ingredient(recipe.id, chicken.id, 100.0)
3055            .unwrap();
3056        db.add_recipe_ingredient(recipe.id, rice.id, 100.0).unwrap();
3057
3058        assert!(
3059            db.remove_recipe_ingredient(recipe.id, "Brown Rice")
3060                .unwrap()
3061        );
3062        let detail = db.get_recipe_detail(recipe.id).unwrap();
3063        assert_eq!(detail.ingredients.len(), 1);
3064        assert!((detail.total_weight_g - 100.0).abs() < 0.01);
3065    }
3066
3067    #[test]
3068    fn test_recipe_log_as_food() {
3069        let db = Database::open_in_memory().unwrap();
3070        let chicken = db.insert_food(&sample_food()).unwrap();
3071        let recipe = db.create_recipe("Meal Prep Chicken", 4.0).unwrap();
3072        db.add_recipe_ingredient(recipe.id, chicken.id, 800.0)
3073            .unwrap();
3074
3075        // Log one portion as a meal
3076        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3077        let food = db.get_food_by_id(recipe.food_id).unwrap();
3078        let serving = food.default_serving_g.unwrap(); // 800/4 = 200g
3079        assert!((serving - 200.0).abs() < 0.01);
3080
3081        let entry = db
3082            .insert_meal_entry(&NewMealEntry {
3083                date,
3084                meal_type: "dinner".to_string(),
3085                food_id: recipe.food_id,
3086                serving_g: serving,
3087                display_unit: None,
3088                display_quantity: None,
3089            })
3090            .unwrap();
3091
3092        // 165 cal/100g * 200g / 100 = 330 kcal
3093        assert!((entry.calories.unwrap() - 330.0).abs() < 0.01);
3094
3095        // Verify daily summary includes it
3096        let summary = db.build_daily_summary(date).unwrap();
3097        assert!((summary.total_calories - 330.0).abs() < 0.01);
3098    }
3099
3100    #[test]
3101    fn test_delete_recipe() {
3102        let db = Database::open_in_memory().unwrap();
3103        let chicken = db.insert_food(&sample_food()).unwrap();
3104        let recipe = db.create_recipe("To Delete", 1.0).unwrap();
3105        db.add_recipe_ingredient(recipe.id, chicken.id, 100.0)
3106            .unwrap();
3107        let food_id = recipe.food_id;
3108
3109        db.delete_recipe(recipe.id).unwrap();
3110        // Virtual food should be gone
3111        assert!(db.get_food_by_id(food_id).is_err());
3112        // Recipe should be gone
3113        assert!(db.get_recipe_by_id(recipe.id).is_err());
3114    }
3115
3116    #[test]
3117    fn test_list_recipes() {
3118        let db = Database::open_in_memory().unwrap();
3119        assert!(db.list_recipes().unwrap().is_empty());
3120
3121        db.create_recipe("Recipe A", 2.0).unwrap();
3122        db.create_recipe("Recipe B", 4.0).unwrap();
3123        let recipes = db.list_recipes().unwrap();
3124        assert_eq!(recipes.len(), 2);
3125    }
3126
3127    // --- Export / Import tests ---
3128
3129    #[test]
3130    fn test_export_all_empty() {
3131        let db = Database::open_in_memory().unwrap();
3132        let export = db.export_all().unwrap();
3133        assert_eq!(export.version, 3);
3134        assert!(export.device_id.is_some());
3135        assert!(export.foods.is_empty());
3136        assert!(export.meal_entries.is_empty());
3137        assert!(export.recipes.is_empty());
3138        assert!(export.recipe_ingredients.is_empty());
3139        assert!(export.targets.is_empty());
3140    }
3141
3142    #[test]
3143    fn test_export_all_with_data() {
3144        let db = Database::open_in_memory().unwrap();
3145        let food = db.insert_food(&sample_food()).unwrap();
3146        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3147        db.insert_meal_entry(&NewMealEntry {
3148            date,
3149            meal_type: "lunch".to_string(),
3150            food_id: food.id,
3151            serving_g: 200.0,
3152            display_unit: None,
3153            display_quantity: None,
3154        })
3155        .unwrap();
3156        db.set_target(0, 2000, Some(30), Some(40), Some(30))
3157            .unwrap();
3158
3159        let export = db.export_all().unwrap();
3160        assert_eq!(export.foods.len(), 1);
3161        assert_eq!(export.meal_entries.len(), 1);
3162        assert_eq!(export.targets.len(), 1);
3163        assert_eq!(export.targets[0].calories, 2000);
3164        assert_eq!(export.targets[0].day_of_week, 0);
3165    }
3166
3167    #[test]
3168    fn test_import_into_empty_db() {
3169        let db = Database::open_in_memory().unwrap();
3170
3171        // Create export data from another db
3172        let source_db = Database::open_in_memory().unwrap();
3173        let food = source_db.insert_food(&sample_food()).unwrap();
3174        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3175        source_db
3176            .insert_meal_entry(&NewMealEntry {
3177                date,
3178                meal_type: "lunch".to_string(),
3179                food_id: food.id,
3180                serving_g: 200.0,
3181                display_unit: None,
3182                display_quantity: None,
3183            })
3184            .unwrap();
3185        source_db
3186            .set_target(0, 2000, Some(30), Some(40), Some(30))
3187            .unwrap();
3188
3189        let export = source_db.export_all().unwrap();
3190        let summary = db.import_all(&export).unwrap();
3191
3192        assert_eq!(summary.foods_imported, 1);
3193        assert_eq!(summary.meal_entries_imported, 1);
3194        assert_eq!(summary.targets_imported, 1);
3195
3196        // Verify data was imported
3197        let imported_food = db.get_food_by_id(food.id).unwrap();
3198        assert_eq!(imported_food.name, "Chicken Breast");
3199        let target = db.get_target(0).unwrap().unwrap();
3200        assert_eq!(target.calories, 2000);
3201    }
3202
3203    #[test]
3204    fn test_import_upsert_existing() {
3205        let db = Database::open_in_memory().unwrap();
3206        let food = db.insert_food(&sample_food()).unwrap();
3207
3208        // Create export with updated food name and bumped updated_at
3209        let export = db.export_all().unwrap();
3210        let mut modified = export;
3211        modified.foods[0].name = "Updated Chicken".to_string();
3212        modified.foods[0].updated_at = "2099-01-01T00:00:00+00:00".to_string();
3213
3214        let summary = db.import_all(&modified).unwrap();
3215        assert_eq!(summary.foods_imported, 1);
3216
3217        let updated_food = db.get_food_by_id(food.id).unwrap();
3218        assert_eq!(updated_food.name, "Updated Chicken");
3219    }
3220
3221    #[test]
3222    fn test_get_recipe_by_food_name() {
3223        let db = Database::open_in_memory().unwrap();
3224        let recipe = db.create_recipe("My Stew", 3.0).unwrap();
3225
3226        // Case-insensitive lookup
3227        let found = db.get_recipe_by_food_name("my stew").unwrap();
3228        assert_eq!(found.id, recipe.id);
3229        let found = db.get_recipe_by_food_name("MY STEW").unwrap();
3230        assert_eq!(found.id, recipe.id);
3231
3232        // Not found
3233        assert!(db.get_recipe_by_food_name("nonexistent").is_err());
3234    }
3235
3236    // --- v2 schema / sync tests ---
3237
3238    #[test]
3239    fn test_insert_food_generates_uuid() {
3240        let db = Database::open_in_memory().unwrap();
3241        let food = db.insert_food(&sample_food()).unwrap();
3242        assert!(!food.uuid.is_empty());
3243        assert!(!food.updated_at.is_empty());
3244        // UUID should be valid v4 format
3245        assert!(uuid::Uuid::parse_str(&food.uuid).is_ok());
3246    }
3247
3248    #[test]
3249    fn test_insert_meal_generates_uuid() {
3250        let db = Database::open_in_memory().unwrap();
3251        let food = db.insert_food(&sample_food()).unwrap();
3252        let entry = db
3253            .insert_meal_entry(&NewMealEntry {
3254                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3255                meal_type: "lunch".to_string(),
3256                food_id: food.id,
3257                serving_g: 200.0,
3258                display_unit: None,
3259                display_quantity: None,
3260            })
3261            .unwrap();
3262        assert!(!entry.uuid.is_empty());
3263        assert!(!entry.updated_at.is_empty());
3264        assert!(uuid::Uuid::parse_str(&entry.uuid).is_ok());
3265    }
3266
3267    #[test]
3268    fn test_update_meal_updates_timestamp() {
3269        let db = Database::open_in_memory().unwrap();
3270        let food = db.insert_food(&sample_food()).unwrap();
3271        let entry = db
3272            .insert_meal_entry(&NewMealEntry {
3273                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3274                meal_type: "lunch".to_string(),
3275                food_id: food.id,
3276                serving_g: 100.0,
3277                display_unit: None,
3278                display_quantity: None,
3279            })
3280            .unwrap();
3281        let original_updated = entry.updated_at.clone();
3282
3283        // Small delay to ensure different timestamp
3284        std::thread::sleep(std::time::Duration::from_millis(10));
3285
3286        let updated = db
3287            .update_meal_entry(
3288                entry.id,
3289                &UpdateMealEntry {
3290                    serving_g: Some(250.0),
3291                    meal_type: None,
3292                    date: None,
3293                    display_unit: None,
3294                    display_quantity: None,
3295                },
3296            )
3297            .unwrap();
3298        assert!(updated.updated_at >= original_updated);
3299        assert_eq!(updated.uuid, entry.uuid); // UUID should not change
3300    }
3301
3302    #[test]
3303    fn test_merge_foods_new() {
3304        let db = Database::open_in_memory().unwrap();
3305        let incoming_uuid = Uuid::new_v4().to_string();
3306        let now = Local::now().to_rfc3339();
3307
3308        let import_data = ExportData {
3309            version: 2,
3310            exported_at: now.clone(),
3311            device_id: Some("other-device".to_string()),
3312            foods: vec![Food {
3313                id: 999,
3314                uuid: incoming_uuid.clone(),
3315                name: "Remote Food".to_string(),
3316                brand: None,
3317                barcode: None,
3318                calories_per_100g: 100.0,
3319                protein_per_100g: Some(10.0),
3320                carbs_per_100g: Some(20.0),
3321                fat_per_100g: Some(5.0),
3322                default_serving_g: None,
3323                source: "manual".to_string(),
3324                created_at: now.clone(),
3325                updated_at: now,
3326            }],
3327            meal_entries: vec![],
3328            recipes: vec![],
3329            recipe_ingredients: vec![],
3330            target: None,
3331            targets: vec![],
3332            weight_entries: vec![],
3333            tombstones: None,
3334        };
3335
3336        let summary = db.import_all(&import_data).unwrap();
3337        assert_eq!(summary.foods_imported, 1);
3338
3339        // Should be findable by UUID
3340        let found = db.get_food_by_uuid(&incoming_uuid).unwrap().unwrap();
3341        assert_eq!(found.name, "Remote Food");
3342    }
3343
3344    #[test]
3345    fn test_merge_foods_newer_wins() {
3346        let db = Database::open_in_memory().unwrap();
3347        let food = db.insert_food(&sample_food()).unwrap();
3348
3349        let import_data = ExportData {
3350            version: 2,
3351            exported_at: Local::now().to_rfc3339(),
3352            device_id: Some("other-device".to_string()),
3353            foods: vec![Food {
3354                id: 999,
3355                uuid: food.uuid.clone(),
3356                name: "Updated Name".to_string(),
3357                brand: None,
3358                barcode: None,
3359                calories_per_100g: 200.0,
3360                protein_per_100g: Some(20.0),
3361                carbs_per_100g: Some(10.0),
3362                fat_per_100g: Some(5.0),
3363                default_serving_g: None,
3364                source: "manual".to_string(),
3365                created_at: food.created_at.clone(),
3366                updated_at: "2099-01-01T00:00:00+00:00".to_string(),
3367            }],
3368            meal_entries: vec![],
3369            recipes: vec![],
3370            recipe_ingredients: vec![],
3371            target: None,
3372            targets: vec![],
3373            weight_entries: vec![],
3374            tombstones: None,
3375        };
3376
3377        let summary = db.import_all(&import_data).unwrap();
3378        assert_eq!(summary.foods_imported, 1);
3379
3380        let updated = db.get_food_by_id(food.id).unwrap();
3381        assert_eq!(updated.name, "Updated Name");
3382        assert_eq!(updated.calories_per_100g, 200.0);
3383    }
3384
3385    #[test]
3386    fn test_merge_foods_older_skipped() {
3387        let db = Database::open_in_memory().unwrap();
3388        let food = db.insert_food(&sample_food()).unwrap();
3389
3390        let import_data = ExportData {
3391            version: 2,
3392            exported_at: Local::now().to_rfc3339(),
3393            device_id: Some("other-device".to_string()),
3394            foods: vec![Food {
3395                id: 999,
3396                uuid: food.uuid.clone(),
3397                name: "Should Not Update".to_string(),
3398                brand: None,
3399                barcode: None,
3400                calories_per_100g: 200.0,
3401                protein_per_100g: None,
3402                carbs_per_100g: None,
3403                fat_per_100g: None,
3404                default_serving_g: None,
3405                source: "manual".to_string(),
3406                created_at: food.created_at.clone(),
3407                updated_at: "2000-01-01T00:00:00+00:00".to_string(),
3408            }],
3409            meal_entries: vec![],
3410            recipes: vec![],
3411            recipe_ingredients: vec![],
3412            target: None,
3413            targets: vec![],
3414            weight_entries: vec![],
3415            tombstones: None,
3416        };
3417
3418        let summary = db.import_all(&import_data).unwrap();
3419        assert_eq!(summary.foods_imported, 0);
3420
3421        let unchanged = db.get_food_by_id(food.id).unwrap();
3422        assert_eq!(unchanged.name, "Chicken Breast");
3423    }
3424
3425    #[test]
3426    fn test_merge_meal_entries() {
3427        let db = Database::open_in_memory().unwrap();
3428        let food = db.insert_food(&sample_food()).unwrap();
3429        let entry_uuid = Uuid::new_v4().to_string();
3430
3431        let import_data = ExportData {
3432            version: 2,
3433            exported_at: Local::now().to_rfc3339(),
3434            device_id: Some("other-device".to_string()),
3435            foods: vec![food.clone()],
3436            meal_entries: vec![ExportMealEntry {
3437                id: 999,
3438                uuid: entry_uuid.clone(),
3439                date: "2024-06-15".to_string(),
3440                meal_type: "lunch".to_string(),
3441                food_id: 999,
3442                food_uuid: food.uuid.clone(),
3443                serving_g: 200.0,
3444                display_unit: None,
3445                display_quantity: None,
3446                created_at: Local::now().to_rfc3339(),
3447                updated_at: Local::now().to_rfc3339(),
3448            }],
3449            recipes: vec![],
3450            recipe_ingredients: vec![],
3451            target: None,
3452            targets: vec![],
3453            weight_entries: vec![],
3454            tombstones: None,
3455        };
3456
3457        let summary = db.import_all(&import_data).unwrap();
3458        assert_eq!(summary.meal_entries_imported, 1);
3459
3460        // Verify the entry exists by checking entries for the date
3461        let entries = db
3462            .get_entries_for_date(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap())
3463            .unwrap();
3464        assert_eq!(entries.len(), 1);
3465        assert_eq!(entries[0].uuid, entry_uuid);
3466        assert_eq!(entries[0].serving_g, 200.0);
3467    }
3468
3469    #[test]
3470    fn test_merge_recipes() {
3471        let db = Database::open_in_memory().unwrap();
3472        let chicken = db.insert_food(&sample_food()).unwrap();
3473
3474        // Create a virtual food for the recipe
3475        let recipe_food = db
3476            .insert_food(&NewFood {
3477                name: "Remote Recipe".to_string(),
3478                brand: None,
3479                barcode: None,
3480                calories_per_100g: 150.0,
3481                protein_per_100g: Some(20.0),
3482                carbs_per_100g: Some(10.0),
3483                fat_per_100g: Some(5.0),
3484                default_serving_g: Some(200.0),
3485                source: "recipe".to_string(),
3486            })
3487            .unwrap();
3488
3489        let recipe_uuid = Uuid::new_v4().to_string();
3490        let ing_uuid = Uuid::new_v4().to_string();
3491        let now = Local::now().to_rfc3339();
3492
3493        let import_data = ExportData {
3494            version: 2,
3495            exported_at: now.clone(),
3496            device_id: Some("other-device".to_string()),
3497            foods: vec![chicken.clone(), recipe_food.clone()],
3498            meal_entries: vec![],
3499            recipes: vec![ExportRecipe {
3500                id: 999,
3501                uuid: recipe_uuid.clone(),
3502                food_id: 999,
3503                food_uuid: recipe_food.uuid.clone(),
3504                portions: 4.0,
3505                created_at: now.clone(),
3506                updated_at: now.clone(),
3507            }],
3508            recipe_ingredients: vec![ExportRecipeIngredient {
3509                id: 999,
3510                uuid: ing_uuid,
3511                recipe_id: 999,
3512                recipe_uuid: recipe_uuid.clone(),
3513                food_id: 999,
3514                food_uuid: chicken.uuid.clone(),
3515                quantity_g: 400.0,
3516            }],
3517            target: None,
3518            targets: vec![],
3519            weight_entries: vec![],
3520            tombstones: None,
3521        };
3522
3523        let summary = db.import_all(&import_data).unwrap();
3524        assert_eq!(summary.recipes_imported, 1);
3525        assert_eq!(summary.recipe_ingredients_imported, 1);
3526    }
3527
3528    #[test]
3529    fn test_merge_tombstones() {
3530        let db = Database::open_in_memory().unwrap();
3531        let food = db.insert_food(&sample_food()).unwrap();
3532        let entry = db
3533            .insert_meal_entry(&NewMealEntry {
3534                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3535                meal_type: "lunch".to_string(),
3536                food_id: food.id,
3537                serving_g: 200.0,
3538                display_unit: None,
3539                display_quantity: None,
3540            })
3541            .unwrap();
3542
3543        let import_data = ExportData {
3544            version: 2,
3545            exported_at: Local::now().to_rfc3339(),
3546            device_id: Some("other-device".to_string()),
3547            foods: vec![],
3548            meal_entries: vec![],
3549            recipes: vec![],
3550            recipe_ingredients: vec![],
3551            target: None,
3552            targets: vec![],
3553            weight_entries: vec![],
3554            tombstones: Some(vec![SyncTombstone {
3555                uuid: entry.uuid.clone(),
3556                table_name: "meal_entries".to_string(),
3557                deleted_at: "2099-01-01T00:00:00+00:00".to_string(),
3558            }]),
3559        };
3560
3561        let summary = db.import_all(&import_data).unwrap();
3562        assert_eq!(summary.tombstones_processed, 1);
3563
3564        // Entry should be deleted
3565        let entries = db
3566            .get_entries_for_date(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap())
3567            .unwrap();
3568        assert!(entries.is_empty());
3569
3570        // Tombstone should be stored locally
3571        let tombstones = db.get_tombstones().unwrap();
3572        assert_eq!(tombstones.len(), 1);
3573        assert_eq!(tombstones[0].uuid, entry.uuid);
3574    }
3575
3576    #[test]
3577    fn test_merge_tombstone_older_than_record() {
3578        let db = Database::open_in_memory().unwrap();
3579        let food = db.insert_food(&sample_food()).unwrap();
3580        let entry = db
3581            .insert_meal_entry(&NewMealEntry {
3582                date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3583                meal_type: "lunch".to_string(),
3584                food_id: food.id,
3585                serving_g: 200.0,
3586                display_unit: None,
3587                display_quantity: None,
3588            })
3589            .unwrap();
3590
3591        // Tombstone has an old deleted_at — record should survive
3592        let import_data = ExportData {
3593            version: 2,
3594            exported_at: Local::now().to_rfc3339(),
3595            device_id: Some("other-device".to_string()),
3596            foods: vec![],
3597            meal_entries: vec![],
3598            recipes: vec![],
3599            recipe_ingredients: vec![],
3600            target: None,
3601            targets: vec![],
3602            weight_entries: vec![],
3603            tombstones: Some(vec![SyncTombstone {
3604                uuid: entry.uuid.clone(),
3605                table_name: "meal_entries".to_string(),
3606                deleted_at: "2000-01-01T00:00:00+00:00".to_string(),
3607            }]),
3608        };
3609
3610        let summary = db.import_all(&import_data).unwrap();
3611        assert_eq!(summary.tombstones_processed, 0);
3612
3613        // Entry should still exist
3614        let entries = db
3615            .get_entries_for_date(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap())
3616            .unwrap();
3617        assert_eq!(entries.len(), 1);
3618    }
3619
3620    #[test]
3621    fn test_v1_import_still_works() {
3622        let db = Database::open_in_memory().unwrap();
3623
3624        // Create a v1 export data (no UUIDs)
3625        let v1_data = ExportData {
3626            version: 1,
3627            exported_at: Local::now().to_rfc3339(),
3628            device_id: None,
3629            foods: vec![Food {
3630                id: 1,
3631                uuid: String::new(),
3632                name: "V1 Food".to_string(),
3633                brand: None,
3634                barcode: None,
3635                calories_per_100g: 100.0,
3636                protein_per_100g: None,
3637                carbs_per_100g: None,
3638                fat_per_100g: None,
3639                default_serving_g: None,
3640                source: "manual".to_string(),
3641                created_at: Local::now().to_rfc3339(),
3642                updated_at: String::new(),
3643            }],
3644            meal_entries: vec![],
3645            recipes: vec![],
3646            recipe_ingredients: vec![],
3647            target: None,
3648            targets: vec![],
3649            weight_entries: vec![],
3650            tombstones: None,
3651        };
3652
3653        let summary = db.import_all(&v1_data).unwrap();
3654        assert_eq!(summary.foods_imported, 1);
3655        assert_eq!(summary.tombstones_processed, 0);
3656
3657        let food = db.get_food_by_id(1).unwrap();
3658        assert_eq!(food.name, "V1 Food");
3659    }
3660
3661    #[test]
3662    fn test_device_id_persistence() {
3663        let db = Database::open_in_memory().unwrap();
3664        let id1 = db.get_or_create_device_id().unwrap();
3665        let id2 = db.get_or_create_device_id().unwrap();
3666        assert_eq!(id1, id2);
3667        assert!(uuid::Uuid::parse_str(&id1).is_ok());
3668    }
3669
3670    #[test]
3671    fn test_export_v2_format() {
3672        let db = Database::open_in_memory().unwrap();
3673        let food = db.insert_food(&sample_food()).unwrap();
3674        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3675        db.insert_meal_entry(&NewMealEntry {
3676            date,
3677            meal_type: "lunch".to_string(),
3678            food_id: food.id,
3679            serving_g: 200.0,
3680            display_unit: None,
3681            display_quantity: None,
3682        })
3683        .unwrap();
3684
3685        let export = db.export_all().unwrap();
3686        assert_eq!(export.version, 3);
3687        assert!(export.device_id.is_some());
3688        assert!(!export.foods[0].uuid.is_empty());
3689        assert!(!export.foods[0].updated_at.is_empty());
3690        assert!(!export.meal_entries[0].uuid.is_empty());
3691        assert!(!export.meal_entries[0].food_uuid.is_empty());
3692        assert_eq!(export.meal_entries[0].food_uuid, food.uuid);
3693        assert!(export.tombstones.is_some());
3694    }
3695
3696    #[test]
3697    fn test_migration_v2_generates_uuids() {
3698        // Simulate a v1 database by creating one, then inserting data at v1 level
3699        // Since open_in_memory runs migrate() which goes all the way to v2,
3700        // we verify that data inserted after migration has UUIDs
3701        let db = Database::open_in_memory().unwrap();
3702        let food = db.insert_food(&sample_food()).unwrap();
3703        assert!(!food.uuid.is_empty());
3704
3705        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3706        let entry = db
3707            .insert_meal_entry(&NewMealEntry {
3708                date,
3709                meal_type: "lunch".to_string(),
3710                food_id: food.id,
3711                serving_g: 100.0,
3712                display_unit: None,
3713                display_quantity: None,
3714            })
3715            .unwrap();
3716        assert!(!entry.uuid.is_empty());
3717
3718        let recipe = db.create_recipe("Test Recipe", 2.0).unwrap();
3719        assert!(!recipe.uuid.is_empty());
3720    }
3721
3722    #[test]
3723    fn test_tombstone_crud() {
3724        let db = Database::open_in_memory().unwrap();
3725
3726        // Initially empty
3727        assert!(db.get_tombstones().unwrap().is_empty());
3728
3729        // Record tombstones
3730        db.record_tombstone("uuid-1", "foods").unwrap();
3731        db.record_tombstone("uuid-2", "meal_entries").unwrap();
3732
3733        let tombstones = db.get_tombstones().unwrap();
3734        assert_eq!(tombstones.len(), 2);
3735
3736        // Clear
3737        db.clear_tombstones().unwrap();
3738        assert!(db.get_tombstones().unwrap().is_empty());
3739    }
3740
3741    // --- Delta sync tests ---
3742
3743    #[test]
3744    fn test_get_foods_since() {
3745        let db = Database::open_in_memory().unwrap();
3746
3747        // Insert two foods
3748        let food1 = db.insert_food(&sample_food()).unwrap();
3749        let food2 = db
3750            .insert_food(&NewFood {
3751                name: "Brown Rice".to_string(),
3752                brand: None,
3753                barcode: None,
3754                calories_per_100g: 112.0,
3755                protein_per_100g: Some(2.6),
3756                carbs_per_100g: Some(23.5),
3757                fat_per_100g: Some(0.9),
3758                default_serving_g: None,
3759                source: "manual".to_string(),
3760            })
3761            .unwrap();
3762
3763        // All foods since epoch should return both
3764        let all = db.get_foods_since("1970-01-01T00:00:00+00:00").unwrap();
3765        assert_eq!(all.len(), 2);
3766
3767        // Foods since a future time should return none
3768        let none = db.get_foods_since("2099-01-01T00:00:00+00:00").unwrap();
3769        assert!(none.is_empty());
3770
3771        // get_all_foods should return both
3772        let all_foods = db.get_all_foods().unwrap();
3773        assert_eq!(all_foods.len(), 2);
3774        assert_eq!(all_foods[0].id, food1.id);
3775        assert_eq!(all_foods[1].id, food2.id);
3776    }
3777
3778    #[test]
3779    fn test_get_meal_entries_since() {
3780        let db = Database::open_in_memory().unwrap();
3781        let food = db.insert_food(&sample_food()).unwrap();
3782
3783        db.insert_meal_entry(&NewMealEntry {
3784            date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3785            meal_type: "lunch".to_string(),
3786            food_id: food.id,
3787            serving_g: 200.0,
3788            display_unit: None,
3789            display_quantity: None,
3790        })
3791        .unwrap();
3792
3793        // All entries since epoch
3794        let all = db
3795            .get_meal_entries_since("1970-01-01T00:00:00+00:00")
3796            .unwrap();
3797        assert_eq!(all.len(), 1);
3798        assert!(!all[0].food_uuid.is_empty());
3799
3800        // None since future
3801        let none = db
3802            .get_meal_entries_since("2099-01-01T00:00:00+00:00")
3803            .unwrap();
3804        assert!(none.is_empty());
3805
3806        // get_all_meal_entries_export
3807        let all_export = db.get_all_meal_entries_export().unwrap();
3808        assert_eq!(all_export.len(), 1);
3809    }
3810
3811    #[test]
3812    fn test_get_tombstones_since() {
3813        let db = Database::open_in_memory().unwrap();
3814
3815        db.record_tombstone("uuid-1", "foods").unwrap();
3816        db.record_tombstone("uuid-2", "meal_entries").unwrap();
3817
3818        let all = db
3819            .get_tombstones_since("1970-01-01T00:00:00+00:00")
3820            .unwrap();
3821        assert_eq!(all.len(), 2);
3822
3823        let none = db
3824            .get_tombstones_since("2099-01-01T00:00:00+00:00")
3825            .unwrap();
3826        assert!(none.is_empty());
3827    }
3828
3829    #[test]
3830    fn test_changes_since_full() {
3831        let db = Database::open_in_memory().unwrap();
3832        let food = db.insert_food(&sample_food()).unwrap();
3833        db.insert_meal_entry(&NewMealEntry {
3834            date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3835            meal_type: "lunch".to_string(),
3836            food_id: food.id,
3837            serving_g: 200.0,
3838            display_unit: None,
3839            display_quantity: None,
3840        })
3841        .unwrap();
3842        db.record_tombstone("dead-uuid", "foods").unwrap();
3843
3844        // Full sync (no since param)
3845        let payload = db.changes_since(None, "2024-06-15T12:00:00Z").unwrap();
3846        assert_eq!(payload.foods.len(), 1);
3847        assert_eq!(payload.meal_entries.len(), 1);
3848        assert_eq!(payload.tombstones.len(), 1);
3849        assert_eq!(payload.server_timestamp, "2024-06-15T12:00:00Z");
3850    }
3851
3852    #[test]
3853    fn test_changes_since_incremental() {
3854        let db = Database::open_in_memory().unwrap();
3855        let food = db.insert_food(&sample_food()).unwrap();
3856
3857        // Record a timestamp after food creation
3858        let mid_timestamp = "2099-01-01T00:00:00+00:00";
3859
3860        // Delta since future returns nothing
3861        let payload = db.changes_since(Some(mid_timestamp), "now").unwrap();
3862        assert!(payload.foods.is_empty());
3863        assert!(payload.meal_entries.is_empty());
3864        assert!(payload.tombstones.is_empty());
3865
3866        // Delta since epoch returns everything
3867        let payload = db
3868            .changes_since(Some("1970-01-01T00:00:00+00:00"), "now")
3869            .unwrap();
3870        assert_eq!(payload.foods.len(), 1);
3871        assert_eq!(payload.foods[0].id, food.id);
3872    }
3873
3874    #[test]
3875    fn test_changes_since_includes_all_entity_types() {
3876        let db = Database::open_in_memory().unwrap();
3877        let food = db.insert_food(&sample_food()).unwrap();
3878
3879        // Create recipe
3880        let recipe = db.create_recipe("Test Recipe", 4.0).unwrap();
3881        db.add_recipe_ingredient(recipe.id, food.id, 200.0).unwrap();
3882
3883        // Set target
3884        db.set_target(0, 2000, Some(40), Some(30), Some(30))
3885            .unwrap();
3886
3887        // Log weight
3888        db.upsert_weight(&NewWeightEntry {
3889            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
3890            weight_kg: 80.0,
3891            source: "manual".to_string(),
3892            notes: None,
3893        })
3894        .unwrap();
3895
3896        let payload = db.changes_since(None, "now").unwrap();
3897        // foods: sample_food + recipe virtual food = 2
3898        assert_eq!(payload.foods.len(), 2);
3899        assert_eq!(payload.recipes.len(), 1);
3900        assert_eq!(payload.recipe_ingredients.len(), 1);
3901        assert_eq!(payload.targets.len(), 1);
3902        assert_eq!(payload.weight_entries.len(), 1);
3903    }
3904
3905    #[test]
3906    fn test_changes_since_incremental_new_entity_types() {
3907        let db = Database::open_in_memory().unwrap();
3908        db.insert_food(&sample_food()).unwrap();
3909
3910        // Create recipe (gets a timestamp)
3911        db.create_recipe("Test Recipe", 4.0).unwrap();
3912
3913        // Set target
3914        db.set_target(0, 2000, Some(40), Some(30), Some(30))
3915            .unwrap();
3916
3917        // Far future — nothing returned
3918        let payload = db
3919            .changes_since(Some("2099-01-01T00:00:00+00:00"), "now")
3920            .unwrap();
3921        assert!(payload.recipes.is_empty());
3922        assert!(payload.targets.is_empty());
3923        assert!(payload.weight_entries.is_empty());
3924        assert!(payload.recipe_ingredients.is_empty());
3925
3926        // Epoch — everything returned
3927        let payload = db
3928            .changes_since(Some("1970-01-01T00:00:00+00:00"), "now")
3929            .unwrap();
3930        assert_eq!(payload.foods.len(), 2); // sample_food + recipe virtual food
3931        assert_eq!(payload.recipes.len(), 1);
3932        assert_eq!(payload.targets.len(), 1);
3933    }
3934
3935    #[test]
3936    fn test_apply_remote_changes_new_food() {
3937        let db = Database::open_in_memory().unwrap();
3938
3939        let incoming_food = Food {
3940            id: 0,
3941            uuid: "remote-uuid-1".to_string(),
3942            name: "Remote Food".to_string(),
3943            brand: Some("Remote Brand".to_string()),
3944            barcode: None,
3945            calories_per_100g: 200.0,
3946            protein_per_100g: Some(20.0),
3947            carbs_per_100g: Some(10.0),
3948            fat_per_100g: Some(5.0),
3949            default_serving_g: Some(100.0),
3950            source: "openfoodfacts".to_string(),
3951            created_at: "2024-01-01T00:00:00+00:00".to_string(),
3952            updated_at: "2024-06-01T00:00:00+00:00".to_string(),
3953        };
3954
3955        db.apply_remote_changes(&[incoming_food], &[], &[], &[], &[], &[], &[])
3956            .unwrap();
3957
3958        let food = db.get_food_by_uuid("remote-uuid-1").unwrap().unwrap();
3959        assert_eq!(food.name, "Remote Food");
3960    }
3961
3962    #[test]
3963    fn test_apply_remote_changes_lww_food() {
3964        let db = Database::open_in_memory().unwrap();
3965        let local = db.insert_food(&sample_food()).unwrap();
3966
3967        let incoming = Food {
3968            id: 0,
3969            uuid: local.uuid.clone(),
3970            name: "Updated Name".to_string(),
3971            brand: Some("New Brand".to_string()),
3972            barcode: local.barcode.clone(),
3973            calories_per_100g: 999.0,
3974            protein_per_100g: Some(99.0),
3975            carbs_per_100g: Some(0.0),
3976            fat_per_100g: Some(0.0),
3977            default_serving_g: Some(100.0),
3978            source: "manual".to_string(),
3979            created_at: local.created_at.clone(),
3980            updated_at: "2099-01-01T00:00:00+00:00".to_string(),
3981        };
3982
3983        db.apply_remote_changes(&[incoming], &[], &[], &[], &[], &[], &[])
3984            .unwrap();
3985
3986        let updated = db.get_food_by_uuid(&local.uuid).unwrap().unwrap();
3987        assert_eq!(updated.name, "Updated Name");
3988        assert_eq!(updated.calories_per_100g, 999.0);
3989    }
3990
3991    #[test]
3992    fn test_apply_remote_changes_lww_food_older_ignored() {
3993        let db = Database::open_in_memory().unwrap();
3994        let local = db.insert_food(&sample_food()).unwrap();
3995
3996        let incoming = Food {
3997            id: 0,
3998            uuid: local.uuid.clone(),
3999            name: "Old Name".to_string(),
4000            brand: None,
4001            barcode: None,
4002            calories_per_100g: 1.0,
4003            protein_per_100g: None,
4004            carbs_per_100g: None,
4005            fat_per_100g: None,
4006            default_serving_g: None,
4007            source: "manual".to_string(),
4008            created_at: "2000-01-01T00:00:00+00:00".to_string(),
4009            updated_at: "2000-01-01T00:00:00+00:00".to_string(),
4010        };
4011
4012        db.apply_remote_changes(&[incoming], &[], &[], &[], &[], &[], &[])
4013            .unwrap();
4014
4015        let unchanged = db.get_food_by_uuid(&local.uuid).unwrap().unwrap();
4016        assert_eq!(unchanged.name, "Chicken Breast");
4017    }
4018
4019    #[test]
4020    fn test_apply_remote_changes_meal_entry() {
4021        let db = Database::open_in_memory().unwrap();
4022        let food = db.insert_food(&sample_food()).unwrap();
4023
4024        let incoming_entry = crate::models::ExportMealEntry {
4025            id: 0,
4026            uuid: "remote-meal-uuid-1".to_string(),
4027            date: "2024-06-15".to_string(),
4028            meal_type: "lunch".to_string(),
4029            food_id: 0,
4030            food_uuid: food.uuid.clone(),
4031            serving_g: 250.0,
4032            display_unit: None,
4033            display_quantity: None,
4034            created_at: "2024-06-15T12:00:00+00:00".to_string(),
4035            updated_at: "2024-06-15T12:00:00+00:00".to_string(),
4036        };
4037
4038        db.apply_remote_changes(&[], &[incoming_entry], &[], &[], &[], &[], &[])
4039            .unwrap();
4040
4041        let entries = db.get_all_meal_entries_export().unwrap();
4042        assert_eq!(entries.len(), 1);
4043        assert_eq!(entries[0].uuid, "remote-meal-uuid-1");
4044        assert_eq!(entries[0].serving_g, 250.0);
4045    }
4046
4047    #[test]
4048    fn test_apply_remote_changes_tombstone() {
4049        let db = Database::open_in_memory().unwrap();
4050        let food = db.insert_food(&sample_food()).unwrap();
4051
4052        let tombstone = SyncTombstone {
4053            uuid: food.uuid.clone(),
4054            table_name: "foods".to_string(),
4055            deleted_at: "2099-01-01T00:00:00+00:00".to_string(),
4056        };
4057
4058        db.apply_remote_changes(&[], &[], &[], &[], &[], &[], &[tombstone])
4059            .unwrap();
4060
4061        assert!(db.get_food_by_uuid(&food.uuid).unwrap().is_none());
4062
4063        let stored = db.get_tombstones().unwrap();
4064        assert_eq!(stored.len(), 1);
4065        assert_eq!(stored[0].uuid, food.uuid);
4066    }
4067
4068    #[test]
4069    fn test_apply_remote_changes_recipes() {
4070        let db = Database::open_in_memory().unwrap();
4071
4072        // Create a virtual food for the recipe
4073        let recipe_food = db
4074            .insert_food(&NewFood {
4075                name: "Remote Recipe".to_string(),
4076                brand: None,
4077                barcode: None,
4078                calories_per_100g: 150.0,
4079                protein_per_100g: Some(20.0),
4080                carbs_per_100g: Some(10.0),
4081                fat_per_100g: Some(5.0),
4082                default_serving_g: Some(200.0),
4083                source: "recipe".to_string(),
4084            })
4085            .unwrap();
4086        let ingredient_food = db.insert_food(&sample_food()).unwrap();
4087
4088        let recipe_uuid = Uuid::new_v4().to_string();
4089        let ing_uuid = Uuid::new_v4().to_string();
4090        let now = Local::now().to_rfc3339();
4091
4092        let recipes = vec![ExportRecipe {
4093            id: 0,
4094            uuid: recipe_uuid.clone(),
4095            food_id: 0,
4096            food_uuid: recipe_food.uuid.clone(),
4097            portions: 4.0,
4098            created_at: now.clone(),
4099            updated_at: now.clone(),
4100        }];
4101
4102        let recipe_ingredients = vec![ExportRecipeIngredient {
4103            id: 0,
4104            uuid: ing_uuid,
4105            recipe_id: 0,
4106            recipe_uuid: recipe_uuid.clone(),
4107            food_id: 0,
4108            food_uuid: ingredient_food.uuid.clone(),
4109            quantity_g: 400.0,
4110        }];
4111
4112        db.apply_remote_changes(&[], &[], &recipes, &recipe_ingredients, &[], &[], &[])
4113            .unwrap();
4114
4115        // Recipe should exist
4116        let imported_recipe = db.get_recipe_by_uuid(&recipe_uuid).unwrap().unwrap();
4117        assert!((imported_recipe.portions - 4.0).abs() < f64::EPSILON);
4118
4119        // Ingredient should exist
4120        let ingredients = db.get_recipe_ingredients(imported_recipe.id).unwrap();
4121        assert_eq!(ingredients.len(), 1);
4122        assert!((ingredients[0].quantity_g - 400.0).abs() < f64::EPSILON);
4123    }
4124
4125    #[test]
4126    fn test_apply_remote_changes_targets_lww() {
4127        let db = Database::open_in_memory().unwrap();
4128
4129        // Set a local target
4130        db.set_target(0, 1800, Some(40), Some(30), Some(30))
4131            .unwrap();
4132
4133        // Apply a newer remote target
4134        let targets = vec![ExportTarget {
4135            day_of_week: 0,
4136            calories: 2200,
4137            protein_pct: Some(35),
4138            carbs_pct: Some(40),
4139            fat_pct: Some(25),
4140            updated_at: Some("2099-01-01T00:00:00+00:00".to_string()),
4141        }];
4142
4143        db.apply_remote_changes(&[], &[], &[], &[], &targets, &[], &[])
4144            .unwrap();
4145
4146        let target = db.get_target(0).unwrap().unwrap();
4147        assert_eq!(target.calories, 2200);
4148        assert_eq!(target.protein_pct, Some(35));
4149    }
4150
4151    #[test]
4152    fn test_apply_remote_changes_targets_older_ignored() {
4153        let db = Database::open_in_memory().unwrap();
4154
4155        // Set a local target (gets current timestamp)
4156        db.set_target(0, 1800, Some(40), Some(30), Some(30))
4157            .unwrap();
4158
4159        // Apply an older remote target
4160        let targets = vec![ExportTarget {
4161            day_of_week: 0,
4162            calories: 1200,
4163            protein_pct: None,
4164            carbs_pct: None,
4165            fat_pct: None,
4166            updated_at: Some("2000-01-01T00:00:00+00:00".to_string()),
4167        }];
4168
4169        db.apply_remote_changes(&[], &[], &[], &[], &targets, &[], &[])
4170            .unwrap();
4171
4172        let target = db.get_target(0).unwrap().unwrap();
4173        assert_eq!(target.calories, 1800); // unchanged
4174    }
4175
4176    #[test]
4177    fn test_apply_remote_changes_weight_entries_lww() {
4178        let db = Database::open_in_memory().unwrap();
4179
4180        // Insert a local weight
4181        db.upsert_weight(&NewWeightEntry {
4182            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
4183            weight_kg: 80.0,
4184            source: "manual".to_string(),
4185            notes: None,
4186        })
4187        .unwrap();
4188
4189        // Apply a newer remote weight for the same date
4190        let weights = vec![ExportWeightEntry {
4191            uuid: Uuid::new_v4().to_string(),
4192            date: "2025-01-15".to_string(),
4193            weight_kg: 79.5,
4194            source: "scale".to_string(),
4195            notes: Some("Smart scale reading".to_string()),
4196            created_at: "2025-01-15T08:00:00+00:00".to_string(),
4197            updated_at: "2099-01-01T00:00:00+00:00".to_string(),
4198        }];
4199
4200        db.apply_remote_changes(&[], &[], &[], &[], &[], &weights, &[])
4201            .unwrap();
4202
4203        let entry = db
4204            .get_weight(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
4205            .unwrap()
4206            .unwrap();
4207        assert!((entry.weight_kg - 79.5).abs() < f64::EPSILON);
4208        assert_eq!(entry.source, "scale");
4209    }
4210
4211    #[test]
4212    fn test_apply_remote_changes_weight_entries_older_ignored() {
4213        let db = Database::open_in_memory().unwrap();
4214
4215        // Insert a local weight (gets current timestamp)
4216        db.upsert_weight(&NewWeightEntry {
4217            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
4218            weight_kg: 80.0,
4219            source: "manual".to_string(),
4220            notes: None,
4221        })
4222        .unwrap();
4223
4224        // Apply an older remote weight for the same date
4225        let weights = vec![ExportWeightEntry {
4226            uuid: Uuid::new_v4().to_string(),
4227            date: "2025-01-15".to_string(),
4228            weight_kg: 75.0,
4229            source: "old_scale".to_string(),
4230            notes: None,
4231            created_at: "2020-01-01T00:00:00+00:00".to_string(),
4232            updated_at: "2020-01-01T00:00:00+00:00".to_string(),
4233        }];
4234
4235        db.apply_remote_changes(&[], &[], &[], &[], &[], &weights, &[])
4236            .unwrap();
4237
4238        let entry = db
4239            .get_weight(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
4240            .unwrap()
4241            .unwrap();
4242        assert!((entry.weight_kg - 80.0).abs() < f64::EPSILON); // unchanged
4243    }
4244
4245    #[test]
4246    fn test_apply_remote_changes_recipe_tombstone() {
4247        let db = Database::open_in_memory().unwrap();
4248        let food = db.insert_food(&sample_food()).unwrap();
4249        let recipe = db.create_recipe("To Delete", 2.0).unwrap();
4250        db.add_recipe_ingredient(recipe.id, food.id, 100.0).unwrap();
4251
4252        let tombstone = SyncTombstone {
4253            uuid: recipe.uuid.clone(),
4254            table_name: "recipes".to_string(),
4255            deleted_at: "2099-01-01T00:00:00+00:00".to_string(),
4256        };
4257
4258        db.apply_remote_changes(&[], &[], &[], &[], &[], &[], &[tombstone])
4259            .unwrap();
4260
4261        assert!(db.get_recipe_by_uuid(&recipe.uuid).unwrap().is_none());
4262    }
4263
4264    // --- Weight entry tests ---
4265
4266    fn sample_weight_entry(date: NaiveDate) -> NewWeightEntry {
4267        NewWeightEntry {
4268            date,
4269            weight_kg: 80.5,
4270            source: "manual".to_string(),
4271            notes: Some("Morning weigh-in".to_string()),
4272        }
4273    }
4274
4275    #[test]
4276    fn test_upsert_weight_creates_new_entry() {
4277        let db = Database::open_in_memory().unwrap();
4278        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4279        let entry = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4280
4281        assert_eq!(entry.date, date);
4282        assert!((entry.weight_kg - 80.5).abs() < f64::EPSILON);
4283        assert_eq!(entry.source, "manual");
4284        assert_eq!(entry.notes.as_deref(), Some("Morning weigh-in"));
4285        assert!(!entry.uuid.is_empty());
4286    }
4287
4288    #[test]
4289    fn test_upsert_weight_replaces_existing_for_same_date() {
4290        let db = Database::open_in_memory().unwrap();
4291        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4292
4293        let first = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4294        assert!((first.weight_kg - 80.5).abs() < f64::EPSILON);
4295
4296        let updated = db
4297            .upsert_weight(&NewWeightEntry {
4298                date,
4299                weight_kg: 79.8,
4300                source: "manual".to_string(),
4301                notes: Some("Evening weigh-in".to_string()),
4302            })
4303            .unwrap();
4304
4305        assert!((updated.weight_kg - 79.8).abs() < f64::EPSILON);
4306        assert_eq!(updated.notes.as_deref(), Some("Evening weigh-in"));
4307
4308        // Should only be one entry for this date
4309        let history = db.get_weight_history(None).unwrap();
4310        assert_eq!(history.len(), 1);
4311    }
4312
4313    #[test]
4314    fn test_get_weight_returns_none_for_missing_date() {
4315        let db = Database::open_in_memory().unwrap();
4316        let date = NaiveDate::from_ymd_opt(2025, 6, 1).unwrap();
4317        let result = db.get_weight(date).unwrap();
4318        assert!(result.is_none());
4319    }
4320
4321    #[test]
4322    fn test_get_weight_returns_entry_for_existing_date() {
4323        let db = Database::open_in_memory().unwrap();
4324        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4325        db.upsert_weight(&sample_weight_entry(date)).unwrap();
4326
4327        let result = db.get_weight(date).unwrap();
4328        assert!(result.is_some());
4329        let entry = result.unwrap();
4330        assert_eq!(entry.date, date);
4331        assert!((entry.weight_kg - 80.5).abs() < f64::EPSILON);
4332    }
4333
4334    #[test]
4335    fn test_get_weight_history_ordered_by_date_desc() {
4336        let db = Database::open_in_memory().unwrap();
4337        let dates = [
4338            NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
4339            NaiveDate::from_ymd_opt(2025, 1, 12).unwrap(),
4340            NaiveDate::from_ymd_opt(2025, 1, 11).unwrap(),
4341        ];
4342        for date in &dates {
4343            db.upsert_weight(&sample_weight_entry(*date)).unwrap();
4344        }
4345
4346        let history = db.get_weight_history(None).unwrap();
4347        assert_eq!(history.len(), 3);
4348        assert_eq!(history[0].date, dates[1]); // 2025-01-12 (most recent)
4349        assert_eq!(history[1].date, dates[2]); // 2025-01-11
4350        assert_eq!(history[2].date, dates[0]); // 2025-01-10
4351    }
4352
4353    #[test]
4354    fn test_get_weight_history_with_days_limit() {
4355        let db = Database::open_in_memory().unwrap();
4356        for day in 1..=5 {
4357            let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
4358            db.upsert_weight(&sample_weight_entry(date)).unwrap();
4359        }
4360
4361        let history = db.get_weight_history(Some(3)).unwrap();
4362        assert_eq!(history.len(), 3);
4363        // Most recent first
4364        assert_eq!(
4365            history[0].date,
4366            NaiveDate::from_ymd_opt(2025, 1, 5).unwrap()
4367        );
4368    }
4369
4370    #[test]
4371    fn test_delete_weight() {
4372        let db = Database::open_in_memory().unwrap();
4373        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4374        let entry = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4375
4376        db.delete_weight(entry.id).unwrap();
4377        let result = db.get_weight(date).unwrap();
4378        assert!(result.is_none());
4379    }
4380
4381    #[test]
4382    fn test_delete_weight_not_found() {
4383        let db = Database::open_in_memory().unwrap();
4384        let result = db.delete_weight(9999);
4385        assert!(result.is_err());
4386    }
4387
4388    #[test]
4389    fn test_export_import_roundtrip_includes_weight_entries() {
4390        let db = Database::open_in_memory().unwrap();
4391
4392        // Add some weight entries
4393        for day in 1..=3 {
4394            let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
4395            db.upsert_weight(&NewWeightEntry {
4396                date,
4397                weight_kg: 80.0 + f64::from(day),
4398                source: "manual".to_string(),
4399                notes: None,
4400            })
4401            .unwrap();
4402        }
4403
4404        let exported = db.export_all().unwrap();
4405        assert_eq!(exported.weight_entries.len(), 3);
4406
4407        // Import into a fresh DB
4408        let db2 = Database::open_in_memory().unwrap();
4409        let summary = db2.import_all(&exported).unwrap();
4410        assert_eq!(summary.weight_entries_imported, 3);
4411
4412        let history = db2.get_weight_history(None).unwrap();
4413        assert_eq!(history.len(), 3);
4414    }
4415
4416    #[test]
4417    fn test_merge_import_weight_lww() {
4418        let db = Database::open_in_memory().unwrap();
4419
4420        // Create an initial weight entry
4421        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4422        let entry = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4423
4424        // Import data with a newer updated_at for the same date
4425        let import_data = ExportData {
4426            version: 2,
4427            exported_at: "2025-01-16T00:00:00Z".to_string(),
4428            device_id: None,
4429            foods: vec![],
4430            meal_entries: vec![],
4431            recipes: vec![],
4432            recipe_ingredients: vec![],
4433            target: None,
4434            targets: vec![],
4435            weight_entries: vec![crate::models::ExportWeightEntry {
4436                uuid: "new-uuid".to_string(),
4437                date: "2025-01-15".to_string(),
4438                weight_kg: 79.0,
4439                source: "apple_health".to_string(),
4440                notes: Some("From Apple Health".to_string()),
4441                created_at: entry.created_at.clone(),
4442                updated_at: "2099-01-01T00:00:00Z".to_string(),
4443            }],
4444            tombstones: None,
4445        };
4446
4447        let summary = db.import_all(&import_data).unwrap();
4448        assert_eq!(summary.weight_entries_imported, 1);
4449
4450        let updated = db.get_weight(date).unwrap().unwrap();
4451        assert!((updated.weight_kg - 79.0).abs() < f64::EPSILON);
4452        assert_eq!(updated.source, "apple_health");
4453    }
4454
4455    #[test]
4456    fn test_migration_creates_weight_entries_table() {
4457        let db = Database::open_in_memory().unwrap();
4458        // If migration ran successfully, we should be able to query the table
4459        let count: i64 = db
4460            .conn
4461            .query_row("SELECT COUNT(*) FROM weight_entries", [], |row| row.get(0))
4462            .unwrap();
4463        assert_eq!(count, 0);
4464    }
4465
4466    // --- Recently logged foods tests ---
4467
4468    #[test]
4469    fn test_recently_logged_foods_empty() {
4470        let db = Database::open_in_memory().unwrap();
4471        let result = db.get_recently_logged_foods(10).unwrap();
4472        assert!(result.is_empty());
4473    }
4474
4475    #[test]
4476    fn test_recently_logged_foods_single_entry() {
4477        let db = Database::open_in_memory().unwrap();
4478        let food = db.insert_food(&sample_food()).unwrap();
4479        db.insert_meal_entry(&NewMealEntry {
4480            date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4481            meal_type: "lunch".to_string(),
4482            food_id: food.id,
4483            serving_g: 200.0,
4484            display_unit: None,
4485            display_quantity: None,
4486        })
4487        .unwrap();
4488
4489        let result = db.get_recently_logged_foods(10).unwrap();
4490        assert_eq!(result.len(), 1);
4491        assert_eq!(result[0].food.id, food.id);
4492        assert!((result[0].last_serving_g - 200.0).abs() < f64::EPSILON);
4493        assert_eq!(result[0].last_meal_type, "lunch");
4494        assert_eq!(result[0].log_count, 1);
4495        assert_eq!(result[0].last_logged, "2024-06-15");
4496    }
4497
4498    #[test]
4499    fn test_recently_logged_foods_ordering_and_dedup() {
4500        let db = Database::open_in_memory().unwrap();
4501        let chicken = db.insert_food(&sample_food()).unwrap();
4502        let rice = db
4503            .insert_food(&NewFood {
4504                name: "Brown Rice".to_string(),
4505                brand: None,
4506                barcode: None,
4507                calories_per_100g: 112.0,
4508                protein_per_100g: Some(2.6),
4509                carbs_per_100g: Some(23.5),
4510                fat_per_100g: Some(0.9),
4511                default_serving_g: None,
4512                source: "manual".to_string(),
4513            })
4514            .unwrap();
4515
4516        // Log chicken 3 times on different dates
4517        for day in [10, 12, 14] {
4518            db.insert_meal_entry(&NewMealEntry {
4519                date: NaiveDate::from_ymd_opt(2024, 6, day).unwrap(),
4520                meal_type: "lunch".to_string(),
4521                food_id: chicken.id,
4522                serving_g: 150.0,
4523                display_unit: None,
4524                display_quantity: None,
4525            })
4526            .unwrap();
4527        }
4528
4529        // Log rice once on a more recent date
4530        db.insert_meal_entry(&NewMealEntry {
4531            date: NaiveDate::from_ymd_opt(2024, 6, 16).unwrap(),
4532            meal_type: "dinner".to_string(),
4533            food_id: rice.id,
4534            serving_g: 250.0,
4535            display_unit: None,
4536            display_quantity: None,
4537        })
4538        .unwrap();
4539
4540        let result = db.get_recently_logged_foods(10).unwrap();
4541        assert_eq!(result.len(), 2);
4542        // Rice is more recent (June 16 vs June 14)
4543        assert_eq!(result[0].food.name, "Brown Rice");
4544        assert_eq!(result[0].log_count, 1);
4545        // Chicken is second
4546        assert_eq!(result[1].food.name, "Chicken Breast");
4547        assert_eq!(result[1].log_count, 3);
4548    }
4549
4550    #[test]
4551    fn test_recently_logged_foods_limit() {
4552        let db = Database::open_in_memory().unwrap();
4553        let food = db.insert_food(&sample_food()).unwrap();
4554        let rice = db
4555            .insert_food(&NewFood {
4556                name: "Brown Rice".to_string(),
4557                brand: None,
4558                barcode: None,
4559                calories_per_100g: 112.0,
4560                protein_per_100g: Some(2.6),
4561                carbs_per_100g: Some(23.5),
4562                fat_per_100g: Some(0.9),
4563                default_serving_g: None,
4564                source: "manual".to_string(),
4565            })
4566            .unwrap();
4567
4568        db.insert_meal_entry(&NewMealEntry {
4569            date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4570            meal_type: "lunch".to_string(),
4571            food_id: food.id,
4572            serving_g: 100.0,
4573            display_unit: None,
4574            display_quantity: None,
4575        })
4576        .unwrap();
4577        db.insert_meal_entry(&NewMealEntry {
4578            date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4579            meal_type: "dinner".to_string(),
4580            food_id: rice.id,
4581            serving_g: 200.0,
4582            display_unit: None,
4583            display_quantity: None,
4584        })
4585        .unwrap();
4586
4587        let result = db.get_recently_logged_foods(1).unwrap();
4588        assert_eq!(result.len(), 1);
4589    }
4590
4591    #[test]
4592    fn test_recently_logged_foods_uses_most_recent_entry() {
4593        let db = Database::open_in_memory().unwrap();
4594        let food = db.insert_food(&sample_food()).unwrap();
4595
4596        // First entry: 100g breakfast
4597        db.insert_meal_entry(&NewMealEntry {
4598            date: NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
4599            meal_type: "breakfast".to_string(),
4600            food_id: food.id,
4601            serving_g: 100.0,
4602            display_unit: None,
4603            display_quantity: None,
4604        })
4605        .unwrap();
4606
4607        // Second (more recent) entry: 250g dinner
4608        db.insert_meal_entry(&NewMealEntry {
4609            date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4610            meal_type: "dinner".to_string(),
4611            food_id: food.id,
4612            serving_g: 250.0,
4613            display_unit: None,
4614            display_quantity: None,
4615        })
4616        .unwrap();
4617
4618        let result = db.get_recently_logged_foods(10).unwrap();
4619        assert_eq!(result.len(), 1);
4620        // Should use the most recent entry's serving/meal
4621        assert!((result[0].last_serving_g - 250.0).abs() < f64::EPSILON);
4622        assert_eq!(result[0].last_meal_type, "dinner");
4623        assert_eq!(result[0].last_logged, "2024-06-15");
4624        assert_eq!(result[0].log_count, 2);
4625    }
4626
4627    // --- Logging streak tests ---
4628
4629    #[test]
4630    fn test_logging_streak_zero_days() {
4631        let db = Database::open_in_memory().unwrap();
4632        let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4633        assert_eq!(db.get_logging_streak(today).unwrap(), 0);
4634    }
4635
4636    #[test]
4637    fn test_logging_streak_one_day_today() {
4638        let db = Database::open_in_memory().unwrap();
4639        let food = db.insert_food(&sample_food()).unwrap();
4640        let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4641
4642        db.insert_meal_entry(&NewMealEntry {
4643            date: today,
4644            meal_type: "lunch".to_string(),
4645            food_id: food.id,
4646            serving_g: 100.0,
4647            display_unit: None,
4648            display_quantity: None,
4649        })
4650        .unwrap();
4651
4652        assert_eq!(db.get_logging_streak(today).unwrap(), 1);
4653    }
4654
4655    #[test]
4656    fn test_logging_streak_starts_from_yesterday() {
4657        let db = Database::open_in_memory().unwrap();
4658        let food = db.insert_food(&sample_food()).unwrap();
4659        let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4660        let yesterday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
4661
4662        // No entry today, but yesterday has one
4663        db.insert_meal_entry(&NewMealEntry {
4664            date: yesterday,
4665            meal_type: "lunch".to_string(),
4666            food_id: food.id,
4667            serving_g: 100.0,
4668            display_unit: None,
4669            display_quantity: None,
4670        })
4671        .unwrap();
4672
4673        assert_eq!(db.get_logging_streak(today).unwrap(), 1);
4674    }
4675
4676    #[test]
4677    fn test_logging_streak_multiple_consecutive_days() {
4678        let db = Database::open_in_memory().unwrap();
4679        let food = db.insert_food(&sample_food()).unwrap();
4680        let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4681
4682        // Log meals for 5 consecutive days ending today
4683        for day in 11..=15 {
4684            db.insert_meal_entry(&NewMealEntry {
4685                date: NaiveDate::from_ymd_opt(2024, 6, day).unwrap(),
4686                meal_type: "lunch".to_string(),
4687                food_id: food.id,
4688                serving_g: 100.0,
4689                display_unit: None,
4690                display_quantity: None,
4691            })
4692            .unwrap();
4693        }
4694
4695        assert_eq!(db.get_logging_streak(today).unwrap(), 5);
4696    }
4697
4698    #[test]
4699    fn test_logging_streak_gap_in_middle() {
4700        let db = Database::open_in_memory().unwrap();
4701        let food = db.insert_food(&sample_food()).unwrap();
4702        let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4703
4704        // Log today, yesterday, skip a day, then another
4705        for day in [15, 14, 12] {
4706            db.insert_meal_entry(&NewMealEntry {
4707                date: NaiveDate::from_ymd_opt(2024, 6, day).unwrap(),
4708                meal_type: "lunch".to_string(),
4709                food_id: food.id,
4710                serving_g: 100.0,
4711                display_unit: None,
4712                display_quantity: None,
4713            })
4714            .unwrap();
4715        }
4716
4717        // Streak should be 2 (today + yesterday), gap on June 13
4718        assert_eq!(db.get_logging_streak(today).unwrap(), 2);
4719    }
4720
4721    #[test]
4722    fn test_logging_streak_no_recent_entries() {
4723        let db = Database::open_in_memory().unwrap();
4724        let food = db.insert_food(&sample_food()).unwrap();
4725        let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4726
4727        // Old entry, not today or yesterday
4728        db.insert_meal_entry(&NewMealEntry {
4729            date: NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
4730            meal_type: "lunch".to_string(),
4731            food_id: food.id,
4732            serving_g: 100.0,
4733            display_unit: None,
4734            display_quantity: None,
4735        })
4736        .unwrap();
4737
4738        assert_eq!(db.get_logging_streak(today).unwrap(), 0);
4739    }
4740
4741    // --- Calorie average tests ---
4742
4743    #[test]
4744    fn test_calorie_average_no_entries() {
4745        let db = Database::open_in_memory().unwrap();
4746        let avg = db.get_calorie_average(7).unwrap();
4747        assert!((avg - 0.0).abs() < f64::EPSILON);
4748    }
4749
4750    #[test]
4751    fn test_calorie_average_single_day() {
4752        let db = Database::open_in_memory().unwrap();
4753        let food = db.insert_food(&sample_food()).unwrap();
4754        let today = Local::now().date_naive();
4755
4756        // 200g of chicken: 165 * 200 / 100 = 330 kcal
4757        db.insert_meal_entry(&NewMealEntry {
4758            date: today,
4759            meal_type: "lunch".to_string(),
4760            food_id: food.id,
4761            serving_g: 200.0,
4762            display_unit: None,
4763            display_quantity: None,
4764        })
4765        .unwrap();
4766
4767        let avg = db.get_calorie_average(7).unwrap();
4768        assert!((avg - 330.0).abs() < 0.01);
4769    }
4770
4771    #[test]
4772    fn test_calorie_average_multiple_days() {
4773        let db = Database::open_in_memory().unwrap();
4774        let food = db.insert_food(&sample_food()).unwrap();
4775        let today = Local::now().date_naive();
4776
4777        // Day 1 (today): 200g = 330 kcal
4778        db.insert_meal_entry(&NewMealEntry {
4779            date: today,
4780            meal_type: "lunch".to_string(),
4781            food_id: food.id,
4782            serving_g: 200.0,
4783            display_unit: None,
4784            display_quantity: None,
4785        })
4786        .unwrap();
4787
4788        // Day 2 (yesterday): 100g = 165 kcal
4789        let yesterday = today - chrono::Duration::days(1);
4790        db.insert_meal_entry(&NewMealEntry {
4791            date: yesterday,
4792            meal_type: "lunch".to_string(),
4793            food_id: food.id,
4794            serving_g: 100.0,
4795            display_unit: None,
4796            display_quantity: None,
4797        })
4798        .unwrap();
4799
4800        // Average of 330 and 165 = 247.5
4801        let avg = db.get_calorie_average(7).unwrap();
4802        assert!((avg - 247.5).abs() < 0.01);
4803    }
4804
4805    #[test]
4806    fn test_calorie_average_skips_zero_days() {
4807        let db = Database::open_in_memory().unwrap();
4808        let food = db.insert_food(&sample_food()).unwrap();
4809        let today = Local::now().date_naive();
4810
4811        // Only log on today: 200g = 330 kcal
4812        db.insert_meal_entry(&NewMealEntry {
4813            date: today,
4814            meal_type: "lunch".to_string(),
4815            food_id: food.id,
4816            serving_g: 200.0,
4817            display_unit: None,
4818            display_quantity: None,
4819        })
4820        .unwrap();
4821
4822        // Averaging over 7 days but only 1 day has entries
4823        // Should return 330 (not 330/7)
4824        let avg = db.get_calorie_average(7).unwrap();
4825        assert!((avg - 330.0).abs() < 0.01);
4826    }
4827
4828    // --- User settings / goal weight tests ---
4829
4830    #[test]
4831    fn test_user_settings_set_get() {
4832        let db = Database::open_in_memory().unwrap();
4833        db.set_setting("test_key", "test_value").unwrap();
4834        let val = db.get_setting("test_key").unwrap();
4835        assert_eq!(val.as_deref(), Some("test_value"));
4836    }
4837
4838    #[test]
4839    fn test_user_settings_get_nonexistent() {
4840        let db = Database::open_in_memory().unwrap();
4841        let val = db.get_setting("nonexistent").unwrap();
4842        assert!(val.is_none());
4843    }
4844
4845    #[test]
4846    fn test_user_settings_upsert() {
4847        let db = Database::open_in_memory().unwrap();
4848        db.set_setting("key", "value1").unwrap();
4849        db.set_setting("key", "value2").unwrap();
4850        let val = db.get_setting("key").unwrap();
4851        assert_eq!(val.as_deref(), Some("value2"));
4852    }
4853
4854    #[test]
4855    fn test_user_settings_delete() {
4856        let db = Database::open_in_memory().unwrap();
4857        db.set_setting("key", "value").unwrap();
4858        assert!(db.delete_setting("key").unwrap());
4859        assert!(db.get_setting("key").unwrap().is_none());
4860        // Deleting again returns false
4861        assert!(!db.delete_setting("key").unwrap());
4862    }
4863
4864    #[test]
4865    fn test_migration_creates_user_settings_table() {
4866        let db = Database::open_in_memory().unwrap();
4867        let count: i64 = db
4868            .conn
4869            .query_row("SELECT COUNT(*) FROM user_settings", [], |row| row.get(0))
4870            .unwrap();
4871        assert_eq!(count, 0);
4872    }
4873}