Skip to main content

smriti/services/
album_suggestions.rs

1//! Album suggestion detection service.
2//!
3//! Analyses photo metadata (location, time, faces) to propose trip and event
4//! albums that the user can accept, dismiss, or ignore.
5
6use std::collections::{HashMap, HashSet};
7use std::hash::{DefaultHasher, Hash, Hasher};
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use chrono::{Datelike, NaiveDate, Timelike, Utc};
11use rusqlite::{params, Connection};
12
13use crate::db::album_suggestion_repo::AlbumSuggestionRepo;
14
15/// A detected suggestion before persistence.
16#[derive(Debug, Clone)]
17pub struct DetectedSuggestion {
18    pub kind: String,
19    pub title: String,
20    pub photo_ids: Vec<i64>,
21    pub cover_photo_id: Option<i64>,
22    pub fingerprint: String,
23}
24
25#[allow(dead_code)]
26#[derive(Debug, Clone, Default)]
27pub struct SuggestionDiagnostics {
28    pub total_photos_with_date: i64,
29    pub photos_with_city: i64,
30    /// Photos that carry GPS lat/lng (with or without a resolved city).
31    /// Distinguishes "no place names yet, run Fill in place names" from
32    /// "your photos have no GPS metadata at all" — those need
33    /// different advice.
34    pub photos_with_gps: i64,
35    pub home_city: Option<String>,
36    pub trip_rows: usize,
37    pub trip_gate_duration_rejected: usize,
38    pub trip_gate_photo_count_rejected: usize,
39    pub trip_gate_rarity_rejected: usize,
40    pub trip_gate_home_distance_rejected: usize,
41    pub trip_gate_album_overlap_rejected: usize,
42    pub trip_candidates_passed: usize,
43    pub event_windows: usize,
44    pub event_gate_photo_count_rejected: usize,
45    pub event_gate_trip_overlap_rejected: usize,
46    pub event_gate_signal_rejected: usize,
47    pub event_gate_album_overlap_rejected: usize,
48    pub event_candidates_passed: usize,
49    pub persisted_new: usize,
50    pub skipped_existing_fingerprint: usize,
51}
52
53// ---------------------------------------------------------------------------
54// Helpers
55// ---------------------------------------------------------------------------
56
57/// Haversine distance in kilometres between two lat/lng pairs.
58pub fn haversine_km(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
59    let r = 6371.0; // Earth radius km
60    let dlat = (lat2 - lat1).to_radians();
61    let dlng = ((lng2 - lng1 + 540.0).rem_euclid(360.0) - 180.0).to_radians();
62    let a = (dlat / 2.0).sin().powi(2)
63        + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlng / 2.0).sin().powi(2);
64    let c = 2.0 * a.sqrt().asin();
65    r * c
66}
67
68fn parse_date_prefix(date_str: &str) -> Option<NaiveDate> {
69    NaiveDate::parse_from_str(date_str.get(..10)?, "%Y-%m-%d").ok()
70}
71
72/// Compute a stable fingerprint from a sorted set of photo IDs.
73/// Uses std DefaultHasher for speed (no crypto needed).
74pub fn compute_fingerprint(photo_ids: &[i64]) -> String {
75    let mut sorted = photo_ids.to_vec();
76    sorted.sort_unstable();
77    let mut hasher = DefaultHasher::new();
78    sorted.hash(&mut hasher);
79    format!("{:016x}", hasher.finish())
80}
81
82/// Pick the best cover photo from a list of IDs. User favourites and the
83/// existing duplicate/burst quality ranking lead; faces and landscape framing
84/// break ties.
85pub fn pick_cover(conn: &Connection, photo_ids: &[i64]) -> Option<i64> {
86    if photo_ids.is_empty() {
87        return None;
88    }
89
90    // Build a comma-separated list for IN clause
91    let placeholders: Vec<String> = photo_ids.iter().map(|_| "?".to_string()).collect();
92    let in_clause = placeholders.join(",");
93
94    let sql = format!(
95        r#"SELECT p.id FROM photos p
96           LEFT JOIN faces f ON p.id = f.photo_id
97           WHERE p.id IN ({})
98             AND p.is_trashed = FALSE
99           GROUP BY p.id
100           ORDER BY
101             p.media_type = 'photo' DESC,
102             p.is_favorite DESC,
103             COALESCE((
104               SELECT MAX(psm.quality_score)
105               FROM photo_stack_members psm
106               WHERE psm.photo_id = p.id
107             ), 0) DESC,
108             COUNT(f.id) > 0 DESC,
109             p.width > p.height DESC,
110             p.date_taken DESC
111           LIMIT 1"#,
112        in_clause,
113    );
114
115    let mut stmt = conn.prepare(&sql).ok()?;
116    let params: Vec<rusqlite::types::Value> = photo_ids
117        .iter()
118        .map(|id| rusqlite::types::Value::Integer(*id))
119        .collect();
120    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params
121        .iter()
122        .map(|v| v as &dyn rusqlite::types::ToSql)
123        .collect();
124    stmt.query_row(&*param_refs, |row| row.get::<_, i64>(0))
125        .ok()
126}
127
128// ---------------------------------------------------------------------------
129// Home city detection
130// ---------------------------------------------------------------------------
131
132/// Determine the user's "home city" — the city with the most distinct photo-weeks.
133/// Returns (city, country, centroid_lat, centroid_lng) or None.
134pub fn detect_home_city(
135    conn: &Connection,
136    override_city: Option<&str>,
137) -> Option<(String, String, f64, f64)> {
138    // If user provided an override, look it up
139    if let Some(city_name) = override_city {
140        if !city_name.trim().is_empty() {
141            let result: Option<(String, String, f64, f64)> = conn
142                .query_row(
143                    r#"SELECT location_city, COALESCE(location_country,''),
144                              AVG(gps_latitude), AVG(gps_longitude)
145                       FROM photos
146                       WHERE location_city = ?1
147                         AND gps_latitude IS NOT NULL
148                         AND is_trashed = FALSE
149                       GROUP BY location_city, COALESCE(location_country,'')
150                       ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
151                       LIMIT 1"#,
152                    params![city_name.trim()],
153                    |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
154                )
155                .ok();
156            if result.is_some() {
157                return result;
158            }
159        }
160    }
161
162    // Auto-detect: city with most distinct weeks of photos
163    conn.query_row(
164        r#"SELECT location_city, COALESCE(location_country,''),
165                  AVG(gps_latitude), AVG(gps_longitude)
166           FROM photos
167           WHERE location_city IS NOT NULL
168             AND gps_latitude IS NOT NULL
169             AND is_trashed = FALSE
170           GROUP BY location_city, COALESCE(location_country,'')
171           ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
172           LIMIT 1"#,
173        [],
174        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
175    )
176    .ok()
177}
178
179// ---------------------------------------------------------------------------
180// Trip detection
181// ---------------------------------------------------------------------------
182
183#[derive(Debug, Clone)]
184struct TripPhoto {
185    id: i64,
186    city: String,
187    country: String,
188    date: NaiveDate,
189    people: HashSet<i64>,
190}
191
192#[derive(Debug, Default)]
193struct TripSpan {
194    start: Option<NaiveDate>,
195    end: Option<NaiveDate>,
196    located_photo_ids: Vec<i64>,
197    stops: Vec<(String, String)>,
198    people: HashSet<i64>,
199}
200
201impl TripSpan {
202    fn push(&mut self, photo: &TripPhoto) {
203        self.start.get_or_insert(photo.date);
204        self.end = Some(photo.date);
205        self.located_photo_ids.push(photo.id);
206        self.people.extend(photo.people.iter().copied());
207        let stop = (photo.city.clone(), photo.country.clone());
208        if self.stops.last() != Some(&stop) {
209            self.stops.push(stop);
210        }
211    }
212
213    fn is_empty(&self) -> bool {
214        self.located_photo_ids.is_empty()
215    }
216}
217
218/// Detect complete journeys rather than independent city visits.
219///
220/// A trip starts when the timeline leaves the inferred home city and ends on a
221/// return home or a long gap. A short quiet stretch may remain connected when
222/// the same people appear on both sides. City changes inside that interval
223/// become ordered stops, so a two-week Jaipur -> Jodhpur -> Udaipur journey is
224/// proposed as one album. Once the dated envelope is known, photos without GPS
225/// are included as well.
226pub fn detect_trips(
227    conn: &Connection,
228    home: Option<&(String, String, f64, f64)>,
229) -> Vec<DetectedSuggestion> {
230    let rows: Vec<TripPhoto> = {
231        let mut stmt = match conn.prepare(
232            r#"SELECT p.id, p.location_city, COALESCE(p.location_country,''), p.date_taken,
233                      (SELECT GROUP_CONCAT(DISTINCT f.cluster_id)
234                       FROM faces f
235                       WHERE f.photo_id = p.id AND f.cluster_id IS NOT NULL)
236               FROM photos p
237               WHERE p.location_city IS NOT NULL AND p.location_city != ''
238                 AND p.date_taken IS NOT NULL
239                 AND p.is_trashed = FALSE
240                 AND COALESCE(p.content_category, 'photo') = 'photo'
241               ORDER BY p.date_taken, p.id"#,
242        ) {
243            Ok(s) => s,
244            Err(_) => return Vec::new(),
245        };
246        stmt.query_map([], |row| {
247            Ok((
248                row.get::<_, i64>(0)?,
249                row.get::<_, String>(1)?,
250                row.get::<_, String>(2)?,
251                row.get::<_, String>(3)?,
252                row.get::<_, Option<String>>(4)?,
253            ))
254        })
255        .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
256        .unwrap_or_else(|e| {
257            tracing::warn!("album trip detection skipped: failed reading city rows: {e}");
258            Vec::new()
259        })
260        .into_iter()
261        .filter_map(|(id, city, country, raw_date, people_csv)| {
262            Some(TripPhoto {
263                id,
264                city,
265                country,
266                date: parse_date_prefix(&raw_date)?,
267                people: people_csv
268                    .as_deref()
269                    .unwrap_or_default()
270                    .split(',')
271                    .filter_map(|value| value.parse().ok())
272                    .collect(),
273            })
274        })
275        .collect()
276    };
277
278    if rows.is_empty() {
279        return Vec::new();
280    }
281
282    let city_centroids: HashMap<(String, String), (f64, f64)> = {
283        let mut stmt = match conn.prepare(
284            r#"SELECT location_city, COALESCE(location_country,''), AVG(gps_latitude), AVG(gps_longitude)
285                   FROM photos
286                   WHERE location_city IS NOT NULL AND gps_latitude IS NOT NULL AND is_trashed = FALSE
287                   GROUP BY location_city, COALESCE(location_country,'')"#,
288        ) {
289            Ok(stmt) => stmt,
290            Err(_) => return Vec::new(),
291        };
292        stmt.query_map([], |row| {
293            Ok((
294                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
295                (row.get::<_, f64>(2)?, row.get::<_, f64>(3)?),
296            ))
297        })
298        .and_then(|iter| iter.collect::<rusqlite::Result<HashMap<_, _>>>())
299        .unwrap_or_else(|e| {
300            tracing::warn!("album trip detection skipped: failed reading city centroid rows: {e}");
301            HashMap::new()
302        })
303    };
304
305    let album_photo_set: HashSet<i64> = {
306        match conn.prepare("SELECT DISTINCT photo_id FROM album_photos") {
307            Ok(mut stmt) => stmt
308                .query_map([], |row| row.get::<_, i64>(0))
309                .and_then(|iter| iter.collect::<rusqlite::Result<HashSet<_>>>())
310                .unwrap_or_else(|e| {
311                    tracing::warn!(
312                        "album trip detection continuing without album membership filter: {e}"
313                    );
314                    HashSet::new()
315                }),
316            Err(_) => HashSet::new(),
317        }
318    };
319
320    let inferred_home: Option<(String, String)> = if home.is_none() {
321        conn.query_row(
322            r#"SELECT location_city, COALESCE(location_country, '')
323               FROM photos
324               WHERE is_trashed = FALSE
325                 AND location_city IS NOT NULL AND location_city != ''
326                 AND date_taken IS NOT NULL
327               GROUP BY location_city, COALESCE(location_country, '')
328               ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
329               LIMIT 1"#,
330            [],
331            |row| Ok((row.get(0)?, row.get(1)?)),
332        )
333        .ok()
334    } else {
335        None
336    };
337    let home_place = home.map(|h| (h.0.as_str(), h.1.as_str())).or_else(|| {
338        inferred_home
339            .as_ref()
340            .map(|(city, country)| (city.as_str(), country.as_str()))
341    });
342    let home_coords = home.map(|h| (h.2, h.3));
343    let is_home = |photo: &TripPhoto| {
344        if home_place == Some((photo.city.as_str(), photo.country.as_str())) {
345            return true;
346        }
347        let place = (photo.city.clone(), photo.country.clone());
348        matches!(
349            (home_coords, city_centroids.get(&place)),
350            (Some((hlat, hlng)), Some((clat, clng)))
351                if haversine_km(hlat, hlng, *clat, *clng) < 50.0
352        )
353    };
354
355    let mut spans = Vec::new();
356    let mut current = TripSpan::default();
357    for photo in &rows {
358        if is_home(photo) {
359            if !current.is_empty() {
360                spans.push(std::mem::take(&mut current));
361            }
362            continue;
363        }
364        if let Some(end) = current.end {
365            let gap = (photo.date - end).num_days();
366            let people_continue = !photo.people.is_disjoint(&current.people);
367            if gap > 10 || (gap > 4 && !people_continue) {
368                spans.push(std::mem::take(&mut current));
369            }
370        }
371        current.push(photo);
372    }
373    if !current.is_empty() {
374        spans.push(current);
375    }
376
377    let mut suggestions = Vec::new();
378    for span in spans {
379        let (Some(start), Some(end)) = (span.start, span.end) else {
380            continue;
381        };
382        let duration_days = (end - start).num_days() + 1;
383        if duration_days < 3 {
384            continue;
385        }
386
387        let mut photo_ids = photos_in_date_envelope(conn, start, end);
388        if photo_ids.is_empty() {
389            photo_ids = span.located_photo_ids;
390        }
391        photo_ids.sort_unstable();
392        photo_ids.dedup();
393        if photo_ids.len() < 15 {
394            continue;
395        }
396
397        let in_album = photo_ids
398            .iter()
399            .filter(|id| album_photo_set.contains(id))
400            .count();
401        if in_album as f64 / photo_ids.len() as f64 > 0.60 {
402            continue;
403        }
404
405        let stops = compact_stops(&span.stops);
406        let destination = trip_destination(&stops);
407        let people = top_named_people(conn, &photo_ids, 2);
408        let lead = if people.is_empty() {
409            if stops.len() > 1 {
410                format!("A journey through {destination}")
411            } else {
412                format!("Time away in {destination}")
413            }
414        } else {
415            format!("{destination} with {}", join_names(&people))
416        };
417        let title = format!("{lead}  ·  {}", format_date_range(start, end));
418
419        let fp = compute_fingerprint(&photo_ids);
420        let cover = pick_cover(conn, &photo_ids);
421
422        suggestions.push(DetectedSuggestion {
423            kind: "trip".to_string(),
424            title,
425            photo_ids,
426            cover_photo_id: cover,
427            fingerprint: fp,
428        });
429    }
430
431    suggestions
432}
433
434fn photos_in_date_envelope(conn: &Connection, start: NaiveDate, end: NaiveDate) -> Vec<i64> {
435    let end_exclusive = end.succ_opt().unwrap_or(end).format("%Y-%m-%d").to_string();
436    let mut stmt = match conn.prepare(
437        r#"SELECT id
438           FROM photos
439           WHERE is_trashed = FALSE
440             AND COALESCE(content_category, 'photo') = 'photo'
441             AND date_taken >= ?1 AND date_taken < ?2
442           ORDER BY date_taken, id"#,
443    ) {
444        Ok(stmt) => stmt,
445        Err(_) => return Vec::new(),
446    };
447    stmt.query_map(
448        params![start.format("%Y-%m-%d").to_string(), end_exclusive],
449        |row| row.get(0),
450    )
451    .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
452    .unwrap_or_default()
453}
454
455fn compact_stops(stops: &[(String, String)]) -> Vec<(String, String)> {
456    let mut out = Vec::new();
457    for stop in stops {
458        if !out.contains(stop) {
459            out.push(stop.clone());
460        }
461    }
462    out
463}
464
465fn trip_destination(stops: &[(String, String)]) -> String {
466    let cities: Vec<&str> = stops.iter().map(|(city, _)| city.as_str()).collect();
467    if cities.len() <= 3 {
468        return join_words(&cities);
469    }
470    let countries: HashSet<&str> = stops
471        .iter()
472        .map(|(_, country)| country.as_str())
473        .filter(|country| !country.is_empty())
474        .collect();
475    if countries.len() == 1 {
476        return countries
477            .into_iter()
478            .next()
479            .unwrap_or("your journey")
480            .to_string();
481    }
482    format!("{} and {} more stops", cities[0], cities.len() - 1)
483}
484
485fn join_words(words: &[&str]) -> String {
486    match words {
487        [] => "your journey".to_string(),
488        [one] => (*one).to_string(),
489        [first, second] => format!("{first} & {second}"),
490        _ => format!("{}, {} & {}", words[0], words[1], words[2]),
491    }
492}
493
494fn join_names(names: &[String]) -> String {
495    let words: Vec<&str> = names.iter().map(String::as_str).collect();
496    join_words(&words)
497}
498
499fn format_date_range(start: NaiveDate, end: NaiveDate) -> String {
500    if start == end {
501        return start.format("%b %-d, %Y").to_string();
502    }
503    if start.year() == end.year() && start.month() == end.month() {
504        format!(
505            "{} – {}, {}",
506            start.format("%b %-d"),
507            end.format("%-d"),
508            start.format("%Y")
509        )
510    } else if start.year() == end.year() {
511        format!("{} – {}", start.format("%b %-d"), end.format("%b %-d, %Y"))
512    } else {
513        format!(
514            "{} – {}",
515            start.format("%b %-d, %Y"),
516            end.format("%b %-d, %Y")
517        )
518    }
519}
520
521fn top_named_people(conn: &Connection, photo_ids: &[i64], limit: usize) -> Vec<String> {
522    if photo_ids.is_empty() || limit == 0 {
523        return Vec::new();
524    }
525    let mut counts: HashMap<String, usize> = HashMap::new();
526    for chunk in photo_ids.chunks(900) {
527        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
528        let sql = format!(
529            r#"SELECT fc.name, COUNT(DISTINCT f.photo_id)
530               FROM faces f
531               JOIN face_clusters fc ON fc.id = f.cluster_id
532               WHERE f.photo_id IN ({placeholders})
533                 AND fc.name IS NOT NULL AND TRIM(fc.name) != ''
534               GROUP BY fc.id, fc.name"#
535        );
536        let Ok(mut stmt) = conn.prepare(&sql) else {
537            return Vec::new();
538        };
539        let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
540            Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
541        }) else {
542            return Vec::new();
543        };
544        for row in rows.flatten() {
545            *counts.entry(row.0).or_default() += row.1;
546        }
547    }
548    let mut ranked: Vec<_> = counts.into_iter().collect();
549    ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
550    ranked
551        .into_iter()
552        .take(limit)
553        .map(|(name, _)| name)
554        .collect()
555}
556
557fn curate_story_photos(conn: &Connection, photo_ids: &[i64]) -> Vec<i64> {
558    if photo_ids.is_empty() {
559        return Vec::new();
560    }
561    let mut out = Vec::new();
562    for chunk in photo_ids.chunks(900) {
563        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
564        let sql = format!(
565            r#"SELECT p.id
566               FROM photos p
567               WHERE p.id IN ({placeholders})
568                 AND p.is_trashed = FALSE
569                 AND COALESCE(p.content_category, 'photo') = 'photo'
570                 AND NOT EXISTS (
571                     SELECT 1
572                     FROM photo_stack_members psm
573                     JOIN photo_stacks ps ON ps.id = psm.stack_id
574                     WHERE psm.photo_id = p.id
575                       AND ps.dismissed = FALSE
576                       AND psm.is_cover = FALSE
577                 )
578               ORDER BY p.date_taken, p.id"#
579        );
580        let Ok(mut stmt) = conn.prepare(&sql) else {
581            return photo_ids.to_vec();
582        };
583        let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
584            row.get::<_, i64>(0)
585        }) else {
586            return photo_ids.to_vec();
587        };
588        out.extend(rows.flatten());
589    }
590    out
591}
592
593// ---------------------------------------------------------------------------
594// Event detection
595// ---------------------------------------------------------------------------
596
597/// Detect event suggestions via a 4-hour sliding window.
598/// An event is a burst of >= 8 photos separated by <= 4 hours from their
599/// neighbours, with a signal check (faces or single-location).
600pub fn detect_events(conn: &Connection, trip_photo_ids: &HashSet<i64>) -> Vec<DetectedSuggestion> {
601    // Query all photos with date_taken, ordered chronologically
602    let rows: Vec<(i64, String, Option<String>, Option<i64>)> = {
603        let mut stmt = match conn.prepare(
604            r#"SELECT p.id, p.date_taken, p.location_city,
605                      (SELECT f.cluster_id FROM faces f WHERE f.photo_id = p.id AND f.cluster_id IS NOT NULL LIMIT 1)
606               FROM photos p
607               WHERE p.date_taken IS NOT NULL AND p.is_trashed = FALSE
608               ORDER BY p.date_taken"#,
609        ) {
610            Ok(s) => s,
611            Err(_) => return Vec::new(),
612        };
613        stmt.query_map([], |row| {
614            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
615        })
616        .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
617        .unwrap_or_else(|e| {
618            tracing::warn!("album event detection skipped: failed reading event rows: {e}");
619            Vec::new()
620        })
621    };
622
623    if rows.is_empty() {
624        return Vec::new();
625    }
626
627    // Photo IDs already in user albums
628    let album_photo_set: HashSet<i64> = {
629        match conn.prepare("SELECT DISTINCT photo_id FROM album_photos") {
630            Ok(mut stmt) => stmt
631                .query_map([], |row| row.get::<_, i64>(0))
632                .and_then(|iter| iter.collect::<rusqlite::Result<HashSet<_>>>())
633                .unwrap_or_else(|e| {
634                    tracing::warn!(
635                        "album event detection continuing without album membership filter: {e}"
636                    );
637                    HashSet::new()
638                }),
639            Err(_) => HashSet::new(),
640        }
641    };
642
643    // Parse timestamps
644    struct EventPhoto {
645        id: i64,
646        ts: i64, // unix epoch seconds
647        city: Option<String>,
648        cluster_id: Option<i64>,
649    }
650
651    let parsed: Vec<EventPhoto> = rows
652        .into_iter()
653        .filter_map(|(id, dt, city, cluster)| {
654            let normalized = if dt.contains('T') {
655                dt.replace('T', " ")
656            } else {
657                dt.clone()
658            };
659            let ts = chrono::NaiveDateTime::parse_from_str(
660                &normalized[..normalized.len().min(19)],
661                "%Y-%m-%d %H:%M:%S",
662            )
663            .ok()
664            .map(|ndt| ndt.and_utc().timestamp())
665            .or_else(|| {
666                parse_date_prefix(&normalized)
667                    .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp())
668            })?;
669            Some(EventPhoto {
670                id,
671                ts,
672                city,
673                cluster_id: cluster,
674            })
675        })
676        .collect();
677
678    // Split into windows at 4-hour gaps
679    let four_hours = 4 * 3600;
680    let mut windows: Vec<Vec<&EventPhoto>> = Vec::new();
681    let mut current_window: Vec<&EventPhoto> = Vec::new();
682
683    for photo in &parsed {
684        if let Some(last) = current_window.last() {
685            if photo.ts - last.ts > four_hours && !current_window.is_empty() {
686                windows.push(std::mem::take(&mut current_window));
687            }
688        }
689        current_window.push(photo);
690    }
691    if !current_window.is_empty() {
692        windows.push(current_window);
693    }
694
695    let mut suggestions = Vec::new();
696
697    for window in &windows {
698        // Gate 1: >= 15 photos. Same noise-reduction reasoning as the
699        // trip detector — short bursts at home aren't event-worthy.
700        if window.len() < 15 {
701            continue;
702        }
703
704        let ids: Vec<i64> = window.iter().map(|p| p.id).collect();
705
706        // Gate 2: not > 70% in trips
707        let in_trip = ids.iter().filter(|id| trip_photo_ids.contains(id)).count();
708        if !ids.is_empty() && in_trip as f64 / ids.len() as f64 > 0.70 {
709            continue;
710        }
711
712        // Gate 3: signal check — 2+ face clusters OR location spans < 3 days
713        let cluster_ids: HashSet<i64> = window.iter().filter_map(|p| p.cluster_id).collect();
714        let distinct_days: HashSet<i64> = window.iter().map(|p| p.ts / 86400).collect();
715        let location_days: HashSet<String> = window.iter().filter_map(|p| p.city.clone()).collect();
716
717        let has_face_signal = cluster_ids.len() >= 2;
718        let has_location_signal = !location_days.is_empty() && distinct_days.len() <= 3;
719
720        if !has_face_signal && !has_location_signal {
721            continue;
722        }
723
724        // Gate 4: not > 60% already in albums
725        let in_album = ids.iter().filter(|id| album_photo_set.contains(id)).count();
726        if !ids.is_empty() && in_album as f64 / ids.len() as f64 > 0.60 {
727            continue;
728        }
729
730        // Title: derive from location or date
731        let primary_city = window
732            .iter()
733            .filter_map(|p| p.city.as_ref())
734            .fold(HashMap::new(), |mut acc, c| {
735                *acc.entry(c.clone()).or_insert(0usize) += 1;
736                acc
737            })
738            .into_iter()
739            .max_by_key(|(_, count)| *count)
740            .map(|(c, _)| c);
741
742        let first_ts = window.first().map(|p| p.ts).unwrap_or(0);
743        let date = chrono::DateTime::from_timestamp(first_ts, 0)
744            .map(|dt| dt.format("%b %d, %Y").to_string())
745            .unwrap_or_else(|| "Event".to_string());
746
747        let people = top_named_people(conn, &ids, 2);
748        let daypart = chrono::DateTime::from_timestamp(first_ts, 0)
749            .map(|dt| match dt.hour() {
750                5..=11 => "A morning",
751                12..=16 => "An afternoon",
752                17..=21 => "An evening",
753                _ => "A night",
754            })
755            .unwrap_or("A day");
756        let title = if !people.is_empty() {
757            if let Some(city) = primary_city.as_ref() {
758                format!(
759                    "{daypart} in {city} with {}  ·  {date}",
760                    join_names(&people)
761                )
762            } else {
763                format!("Time with {}  ·  {date}", join_names(&people))
764            }
765        } else if let Some(city) = primary_city {
766            format!("{daypart} in {city}  ·  {date}")
767        } else {
768            format!("A day worth keeping  ·  {date}")
769        };
770
771        let fp = compute_fingerprint(&ids);
772        let cover = pick_cover(conn, &ids);
773
774        suggestions.push(DetectedSuggestion {
775            kind: "event".to_string(),
776            title,
777            photo_ids: ids,
778            cover_photo_id: cover,
779            fingerprint: fp,
780        });
781    }
782
783    suggestions
784}
785
786// ---------------------------------------------------------------------------
787// Gatherings detector — face-driven, no GPS required.
788// ---------------------------------------------------------------------------
789
790/// A "gathering" is a 1–2 day window with ≥ 8 photos that contain at
791/// least 2 distinct named-or-unnamed face clusters. The intent is to
792/// catch family weekends, parties, and outings on libraries that
793/// have no location metadata at all (old phones, scanned DSLR exports).
794///
795/// We exclude photos already claimed by trip / event detectors so the
796/// same weekend doesn't get suggested twice from different angles.
797pub fn detect_gatherings(
798    conn: &Connection,
799    excluded_photo_ids: &HashSet<i64>,
800) -> Vec<DetectedSuggestion> {
801    // Pull every photo that has at least one face assigned to a cluster,
802    // ordered by date. Group key is the day bucket (UTC date).
803    let mut stmt = match conn.prepare(
804        r#"
805        SELECT DISTINCT
806            p.id,
807            CAST(strftime('%s', p.date_taken) AS INTEGER) AS ts,
808            f.cluster_id
809        FROM photos p
810        JOIN faces  f ON f.photo_id = p.id
811        WHERE p.is_trashed = FALSE
812          AND p.date_taken IS NOT NULL
813          AND f.cluster_id IS NOT NULL
814        ORDER BY p.date_taken
815        "#,
816    ) {
817        Ok(s) => s,
818        Err(_) => return Vec::new(),
819    };
820
821    type Row = (i64, i64, i64); // (photo_id, ts, cluster_id)
822    let rows: Vec<Row> = stmt
823        .query_map([], |row| {
824            Ok((
825                row.get::<_, i64>(0)?,
826                row.get::<_, i64>(1)?,
827                row.get::<_, i64>(2)?,
828            ))
829        })
830        .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
831        .unwrap_or_else(|e| {
832            tracing::warn!("album people-gathering detection skipped: failed reading rows: {e}");
833            Vec::new()
834        });
835
836    if rows.is_empty() {
837        return Vec::new();
838    }
839
840    // Cluster names lookup — used to title the resulting album.
841    let cluster_names: HashMap<i64, Option<String>> = {
842        let mut s = match conn.prepare("SELECT id, name FROM face_clusters") {
843            Ok(s) => s,
844            Err(_) => return Vec::new(),
845        };
846        s.query_map([], |row| {
847            Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?))
848        })
849        .and_then(|iter| iter.collect::<rusqlite::Result<HashMap<_, _>>>())
850        .unwrap_or_else(|e| {
851            tracing::warn!(
852                "album people-gathering detection continuing without cluster names: {e}"
853            );
854            HashMap::new()
855        })
856    };
857
858    // Sliding-window grouping by day bucket. Two consecutive days (or
859    // a Sat→Sun) get fused into one gathering when they share clusters.
860    let secs_per_day: i64 = 86_400;
861    let max_span_days: i64 = 2;
862
863    struct Window {
864        start_ts: i64,
865        end_ts: i64,
866        photo_ids: Vec<i64>,
867        cluster_ids: HashSet<i64>,
868    }
869
870    let mut windows: Vec<Window> = Vec::new();
871    let mut cur: Option<Window> = None;
872    let mut last_seen_photo: HashMap<i64, i64> = HashMap::new(); // photo_id → ts (dedup ts across cluster rows)
873
874    for (photo_id, ts, cluster_id) in &rows {
875        let ts = *ts;
876        let pid = *photo_id;
877        let cid = *cluster_id;
878        let mut new_window = false;
879        if let Some(c) = &cur {
880            // Extend if within span.
881            if (ts - c.start_ts) <= max_span_days * secs_per_day {
882                // ok, extend
883            } else {
884                new_window = true;
885            }
886        } else {
887            new_window = true;
888        }
889        if new_window {
890            if let Some(w) = cur.take() {
891                if !w.photo_ids.is_empty() {
892                    windows.push(w);
893                }
894            }
895            cur = Some(Window {
896                start_ts: ts,
897                end_ts: ts,
898                photo_ids: Vec::new(),
899                cluster_ids: HashSet::new(),
900            });
901        }
902        let Some(w) = cur.as_mut() else {
903            continue;
904        };
905        w.cluster_ids.insert(cid);
906        w.end_ts = w.end_ts.max(ts);
907        // Avoid double-pushing the same photo (it appears once per
908        // cluster row from the JOIN).
909        let prev_ts = last_seen_photo.get(&pid).copied();
910        if prev_ts.is_none() {
911            w.photo_ids.push(pid);
912        }
913        last_seen_photo.insert(pid, ts);
914    }
915    if let Some(w) = cur.take() {
916        if !w.photo_ids.is_empty() {
917            windows.push(w);
918        }
919    }
920
921    let mut out = Vec::new();
922    for mut w in windows {
923        // Drop photos already used by other detectors.
924        w.photo_ids.retain(|p| !excluded_photo_ids.contains(p));
925        if w.photo_ids.len() < 8 || w.cluster_ids.len() < 2 {
926            continue;
927        }
928
929        // Pick the 1-2 most-photographed clusters in the window for
930        // the title. Counting requires a second pass over rows in the
931        // window range — cheap, the window is small.
932        let mut counts: HashMap<i64, usize> = HashMap::new();
933        for (pid, _, cid) in &rows {
934            if w.photo_ids.contains(pid) {
935                *counts.entry(*cid).or_insert(0) += 1;
936            }
937        }
938        let mut sorted: Vec<(i64, usize)> = counts.into_iter().collect();
939        sorted.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
940        let top_names: Vec<String> = sorted
941            .iter()
942            .take(2)
943            .filter_map(|(cid, _)| cluster_names.get(cid).and_then(|n| n.clone()))
944            .collect();
945
946        // Build a date suffix from the window's actual span.
947        let start_dt = chrono::DateTime::<Utc>::from_timestamp(w.start_ts, 0);
948        let end_dt = chrono::DateTime::<Utc>::from_timestamp(w.end_ts, 0);
949        let date_part = match (start_dt, end_dt) {
950            (Some(s), Some(e)) if s.date_naive() == e.date_naive() => {
951                s.format("%b %-d, %Y").to_string()
952            }
953            (Some(s), Some(e)) => format!("{} – {}", s.format("%b %-d"), e.format("%b %-d, %Y")),
954            _ => String::from("—"),
955        };
956
957        let title = if top_names.is_empty() {
958            format!("Gathering · {}", date_part)
959        } else if top_names.len() == 1 {
960            format!("Time with {} · {}", top_names[0], date_part)
961        } else {
962            format!("{} & {} · {}", top_names[0], top_names[1], date_part)
963        };
964
965        let cover_photo_id = pick_cover(conn, &w.photo_ids);
966        let fingerprint = compute_fingerprint(&w.photo_ids);
967
968        out.push(DetectedSuggestion {
969            kind: "gathering".to_string(),
970            title,
971            photo_ids: w.photo_ids,
972            cover_photo_id,
973            fingerprint,
974        });
975    }
976
977    out
978}
979
980// ---------------------------------------------------------------------------
981// Top-level pipeline
982// ---------------------------------------------------------------------------
983
984/// Run the full suggestion detection pipeline: trips then events.
985/// Newly detected suggestions that don't match existing fingerprints are
986/// persisted to the database.
987#[allow(dead_code)]
988pub fn detect_suggestions(
989    conn: &Connection,
990    home_city_override: Option<&str>,
991) -> Vec<DetectedSuggestion> {
992    detect_suggestions_with_diagnostics(conn, home_city_override).0
993}
994
995pub fn detect_suggestions_with_diagnostics(
996    conn: &Connection,
997    home_city_override: Option<&str>,
998) -> (Vec<DetectedSuggestion>, SuggestionDiagnostics) {
999    detect_suggestions_with_diagnostics_cancel(conn, home_city_override, None)
1000}
1001
1002pub fn detect_suggestions_with_diagnostics_cancel(
1003    conn: &Connection,
1004    home_city_override: Option<&str>,
1005    cancel: Option<&AtomicBool>,
1006) -> (Vec<DetectedSuggestion>, SuggestionDiagnostics) {
1007    let mut diag = SuggestionDiagnostics {
1008        total_photos_with_date: conn
1009            .query_row(
1010                "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND date_taken IS NOT NULL",
1011                [],
1012                |row| row.get(0),
1013            )
1014            .unwrap_or(0),
1015        photos_with_city: conn
1016            .query_row(
1017                "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND location_city IS NOT NULL AND location_city != ''",
1018                [],
1019                |row| row.get(0),
1020            )
1021            .unwrap_or(0),
1022        photos_with_gps: conn
1023            .query_row(
1024                "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND gps_latitude IS NOT NULL AND gps_longitude IS NOT NULL",
1025                [],
1026                |row| row.get(0),
1027            )
1028            .unwrap_or(0),
1029        ..SuggestionDiagnostics::default()
1030    };
1031
1032    if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1033        return (Vec::new(), diag);
1034    }
1035
1036    let repo = AlbumSuggestionRepo::new(conn);
1037    let existing_fps: HashSet<String> = repo
1038        .get_all_fingerprints()
1039        .unwrap_or_default()
1040        .into_iter()
1041        .collect();
1042
1043    if diag.total_photos_with_date < 20 {
1044        tracing::info!(
1045            "suggestions: insufficient dated photos ({})",
1046            diag.total_photos_with_date
1047        );
1048        return (Vec::new(), diag);
1049    }
1050
1051    let home = detect_home_city(conn, home_city_override);
1052    diag.home_city = home.as_ref().map(|h| h.0.clone());
1053    let trips = detect_trips(conn, home.as_ref());
1054    diag.trip_candidates_passed = trips.len();
1055
1056    if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1057        return (Vec::new(), diag);
1058    }
1059
1060    // Coarse gate diagnostics for trips.
1061    {
1062        let mut stmt = conn
1063            .prepare(
1064                r#"SELECT p.id, p.location_city, COALESCE(p.location_country,''), p.date_taken
1065                   FROM photos p
1066                   WHERE p.location_city IS NOT NULL
1067                     AND p.date_taken IS NOT NULL
1068                     AND p.is_trashed = FALSE
1069                   ORDER BY p.location_city, COALESCE(p.location_country,''), p.date_taken"#,
1070            )
1071            .ok();
1072        if let Some(ref mut stmt) = stmt {
1073            let rows: Vec<(i64, String, String, String)> = stmt
1074                .query_map([], |row| {
1075                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
1076                })
1077                .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
1078                .unwrap_or_default();
1079            diag.trip_rows = rows.len();
1080
1081            let total_weeks: i64 = conn
1082                .query_row(
1083                    "SELECT COUNT(DISTINCT strftime('%Y-%W', date_taken)) FROM photos WHERE date_taken IS NOT NULL AND is_trashed = FALSE",
1084                    [],
1085                    |row| row.get(0),
1086                )
1087                .unwrap_or(1)
1088                .max(1);
1089            let city_weeks: HashMap<(String, String), i64> = {
1090                match conn.prepare(
1091                    r#"SELECT location_city, COALESCE(location_country,''), COUNT(DISTINCT strftime('%Y-%W', date_taken))
1092                           FROM photos
1093                           WHERE location_city IS NOT NULL AND date_taken IS NOT NULL AND is_trashed = FALSE
1094                           GROUP BY location_city, COALESCE(location_country,'')"#,
1095                ) {
1096                    Ok(mut stmt) => stmt
1097                        .query_map([], |row| {
1098                            Ok((
1099                                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
1100                                row.get::<_, i64>(2)?,
1101                            ))
1102                        })
1103                        .and_then(|iter| iter.collect::<rusqlite::Result<HashMap<_, _>>>())
1104                        .unwrap_or_default(),
1105                    Err(_) => HashMap::new(),
1106                }
1107            };
1108
1109            let mut spans: Vec<(String, String, NaiveDate, NaiveDate, usize)> = Vec::new();
1110            let mut city = String::new();
1111            let mut country = String::new();
1112            let mut dates: Vec<NaiveDate> = Vec::new();
1113            let mut flush = |city: &str, country: &str, dates: &mut Vec<NaiveDate>| {
1114                if city.is_empty() || dates.is_empty() {
1115                    dates.clear();
1116                    return;
1117                }
1118                dates.sort();
1119                let mut start = dates[0];
1120                let mut end = dates[0];
1121                let mut count = 1usize;
1122                for d in dates.iter().skip(1).copied() {
1123                    if (d - end).num_days() > 3 {
1124                        spans.push((city.to_string(), country.to_string(), start, end, count));
1125                        start = d;
1126                        count = 0;
1127                    }
1128                    end = d;
1129                    count += 1;
1130                }
1131                spans.push((city.to_string(), country.to_string(), start, end, count));
1132                dates.clear();
1133            };
1134            for (_, c, co, ds) in rows {
1135                let Some(d) = parse_date_prefix(&ds) else {
1136                    continue;
1137                };
1138                if c != city || co != country {
1139                    flush(&city, &country, &mut dates);
1140                    city = c;
1141                    country = co;
1142                }
1143                dates.push(d);
1144            }
1145            flush(&city, &country, &mut dates);
1146
1147            for (city, country, start, end, count) in spans {
1148                let duration_days = (end - start).num_days() + 1;
1149                if duration_days < 3 {
1150                    diag.trip_gate_duration_rejected += 1;
1151                    continue;
1152                }
1153                if count < 15 {
1154                    diag.trip_gate_photo_count_rejected += 1;
1155                    continue;
1156                }
1157                let cw = city_weeks.get(&(city, country)).copied().unwrap_or(0);
1158                if cw as f64 / total_weeks as f64 >= 0.10 {
1159                    diag.trip_gate_rarity_rejected += 1;
1160                    continue;
1161                }
1162            }
1163        }
1164    }
1165
1166    // Collect all trip photo IDs for the event filter gate
1167    let trip_photo_ids: HashSet<i64> = trips
1168        .iter()
1169        .flat_map(|t| t.photo_ids.iter().copied())
1170        .collect();
1171
1172    let events = detect_events(conn, &trip_photo_ids);
1173    diag.event_candidates_passed = events.len();
1174
1175    if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1176        return (Vec::new(), diag);
1177    }
1178
1179    // Gatherings: face-driven, no GPS needed. Catches "weekend with
1180    // people" scenarios that trips and events miss on libraries
1181    // without location metadata.
1182    let already_used: HashSet<i64> = trips
1183        .iter()
1184        .chain(events.iter())
1185        .flat_map(|s| s.photo_ids.iter().copied())
1186        .collect();
1187    let gatherings = detect_gatherings(conn, &already_used);
1188
1189    let mut all: Vec<DetectedSuggestion> = Vec::new();
1190    all.extend(trips);
1191    all.extend(events);
1192    all.extend(gatherings);
1193    for suggestion in &mut all {
1194        suggestion.photo_ids = curate_story_photos(conn, &suggestion.photo_ids);
1195        suggestion.cover_photo_id = pick_cover(conn, &suggestion.photo_ids);
1196        suggestion.fingerprint = compute_fingerprint(&suggestion.photo_ids);
1197    }
1198    all.retain(|suggestion| !suggestion.photo_ids.is_empty());
1199
1200    // Persist only new suggestions (no matching fingerprint)
1201    let mut persisted = Vec::new();
1202    for s in all {
1203        if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1204            break;
1205        }
1206        if existing_fps.contains(&s.fingerprint) {
1207            diag.skipped_existing_fingerprint += 1;
1208            continue;
1209        }
1210        match repo.insert(
1211            &s.kind,
1212            &s.title,
1213            &s.photo_ids,
1214            s.cover_photo_id,
1215            &s.fingerprint,
1216        ) {
1217            Ok(_id) => {
1218                tracing::info!("New {} suggestion: {}", s.kind, s.title);
1219                persisted.push(s);
1220                diag.persisted_new += 1;
1221            }
1222            Err(e) => {
1223                tracing::warn!("Failed to insert suggestion: {}", e);
1224            }
1225        }
1226    }
1227
1228    // Cleanup old non-pending records (> 180 days)
1229    let _ = repo.cleanup_old(180);
1230
1231    tracing::info!(
1232        "suggestions diagnostics: dated={}, with_city={}, home_city={:?}, trip_passed={}, event_passed={}, persisted={}, skipped_fp={}",
1233        diag.total_photos_with_date,
1234        diag.photos_with_city,
1235        diag.home_city,
1236        diag.trip_candidates_passed,
1237        diag.event_candidates_passed,
1238        diag.persisted_new,
1239        diag.skipped_existing_fingerprint
1240    );
1241
1242    (persisted, diag)
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use rusqlite::Connection;
1249    use std::sync::atomic::AtomicBool;
1250
1251    fn create_trip_test_schema(conn: &Connection) {
1252        conn.execute_batch(
1253            r#"
1254            CREATE TABLE photos (
1255                id INTEGER PRIMARY KEY,
1256                location_city TEXT,
1257                location_country TEXT,
1258                date_taken TEXT,
1259                gps_latitude REAL,
1260                gps_longitude REAL,
1261                is_trashed BOOLEAN DEFAULT FALSE
1262            );
1263            "#,
1264        )
1265        .unwrap();
1266    }
1267
1268    #[test]
1269    fn detect_trips_tolerates_missing_album_photos_table() {
1270        let conn = Connection::open_in_memory().unwrap();
1271        create_trip_test_schema(&conn);
1272        conn.execute_batch(
1273            r#"
1274            INSERT INTO photos
1275                (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1276            VALUES
1277                (1, 'Goa', 'India', '2024-01-01T10:00:00Z', 15.2993, 74.1240, FALSE),
1278                (2, 'Goa', 'India', '2024-01-02T10:00:00Z', 15.2993, 74.1240, FALSE),
1279                (3, 'Goa', 'India', '2024-01-03T10:00:00Z', 15.2993, 74.1240, FALSE);
1280            "#,
1281        )
1282        .unwrap();
1283
1284        let suggestions = detect_trips(&conn, None);
1285        assert!(suggestions.is_empty());
1286    }
1287
1288    #[test]
1289    fn detect_trips_does_not_merge_same_city_name_across_countries() {
1290        let conn = Connection::open_in_memory().unwrap();
1291        create_trip_test_schema(&conn);
1292        let mut id = 1;
1293        for country in ["Canada", "United Kingdom"] {
1294            for day in 1..=4 {
1295                for shot in 0..2 {
1296                    conn.execute(
1297                        r#"INSERT INTO photos
1298                           (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1299                           VALUES (?1, 'London', ?2, ?3, 51.5, -0.1, FALSE)"#,
1300                        params![
1301                            id,
1302                            country,
1303                            format!("2024-01-{day:02}T10:00:{shot:02}Z")
1304                        ],
1305                    )
1306                    .unwrap();
1307                    id += 1;
1308                }
1309            }
1310        }
1311        for week in 1..=20 {
1312            let date = NaiveDate::from_ymd_opt(2023, 1, 1)
1313                .unwrap()
1314                .checked_add_days(chrono::Days::new((week - 1) * 7))
1315                .unwrap();
1316            conn.execute(
1317                "INSERT INTO photos (id, date_taken, is_trashed) VALUES (?1, ?2, FALSE)",
1318                params![id, format!("{date}T00:00:00Z")],
1319            )
1320            .unwrap();
1321            id += 1;
1322        }
1323
1324        let suggestions = detect_trips(&conn, None);
1325        assert!(suggestions.is_empty());
1326    }
1327
1328    #[test]
1329    fn detect_trips_keeps_multiple_stops_and_unlocated_photos_in_one_journey() {
1330        let conn = Connection::open_in_memory().unwrap();
1331        crate::db::schema::create_schema(&conn).unwrap();
1332        let stops = [
1333            ("Jaipur", 1_u32, 4_u32, 26.9124, 75.7873),
1334            ("Jodhpur", 5, 8, 26.2389, 73.0243),
1335            ("Udaipur", 9, 12, 24.5854, 73.7125),
1336        ];
1337        let mut id = 1_i64;
1338        for (city, first_day, last_day, lat, lng) in stops {
1339            for day in first_day..=last_day {
1340                for shot in 0..2 {
1341                    conn.execute(
1342                        r#"INSERT INTO photos
1343                           (id, file_path, file_name, file_hash, file_size, date_taken,
1344                            gps_latitude, gps_longitude, location_city, location_country)
1345                           VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, 'India')"#,
1346                        params![
1347                            id,
1348                            format!("{id}.jpg"),
1349                            format!("{id}.jpg"),
1350                            format!("hash-{id}"),
1351                            format!("2024-03-{day:02}T10:00:{shot:02}Z"),
1352                            lat,
1353                            lng,
1354                            city,
1355                        ],
1356                    )
1357                    .unwrap();
1358                    id += 1;
1359                }
1360            }
1361        }
1362        let unlocated_id = id;
1363        conn.execute(
1364            r#"INSERT INTO photos
1365               (id, file_path, file_name, file_hash, file_size, date_taken)
1366               VALUES (?1, ?2, ?3, ?4, 1, '2024-03-06T20:00:00Z')"#,
1367            params![
1368                unlocated_id,
1369                format!("{unlocated_id}.jpg"),
1370                format!("{unlocated_id}.jpg"),
1371                format!("hash-{unlocated_id}")
1372            ],
1373        )
1374        .unwrap();
1375
1376        let home = ("Delhi".to_string(), "India".to_string(), 28.6139, 77.2090);
1377        let suggestions = detect_trips(&conn, Some(&home));
1378
1379        assert_eq!(suggestions.len(), 1);
1380        let trip = &suggestions[0];
1381        assert!(trip.title.contains("Jaipur, Jodhpur & Udaipur"));
1382        assert!(trip.photo_ids.contains(&unlocated_id));
1383        assert_eq!(trip.photo_ids.len(), 25);
1384    }
1385
1386    #[test]
1387    fn suggestion_detection_skips_short_malformed_dates() {
1388        let conn = Connection::open_in_memory().unwrap();
1389        conn.execute_batch(
1390            r#"
1391            CREATE TABLE photos (
1392                id INTEGER PRIMARY KEY,
1393                location_city TEXT,
1394                location_country TEXT,
1395                date_taken TEXT,
1396                gps_latitude REAL,
1397                gps_longitude REAL,
1398                is_trashed BOOLEAN DEFAULT FALSE
1399            );
1400            CREATE TABLE album_suggestions (
1401                id INTEGER PRIMARY KEY,
1402                kind TEXT NOT NULL,
1403                title TEXT NOT NULL,
1404                photo_ids_json TEXT NOT NULL,
1405                cover_photo_id INTEGER,
1406                fingerprint TEXT NOT NULL,
1407                status TEXT NOT NULL,
1408                seen_count INTEGER NOT NULL DEFAULT 0,
1409                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
1410            );
1411            "#,
1412        )
1413        .unwrap();
1414        for id in 1..=22 {
1415            conn.execute(
1416                r#"INSERT INTO photos
1417                   (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1418                   VALUES (?1, 'Goa', 'India', 'bad', 15.2993, 74.1240, FALSE)"#,
1419                params![id],
1420            )
1421            .unwrap();
1422        }
1423
1424        let (suggestions, _diag) = detect_suggestions_with_diagnostics_cancel(&conn, None, None);
1425        assert!(suggestions.is_empty());
1426    }
1427
1428    #[test]
1429    fn haversine_uses_shortest_path_across_date_line() {
1430        assert!(haversine_km(0.0, 179.9, 0.0, -179.9) < 25.0);
1431    }
1432
1433    #[test]
1434    fn cancelled_detection_persists_no_suggestions() {
1435        let conn = Connection::open_in_memory().unwrap();
1436        conn.execute_batch(
1437            r#"
1438            CREATE TABLE photos (
1439                id INTEGER PRIMARY KEY,
1440                location_city TEXT,
1441                location_country TEXT,
1442                date_taken TEXT,
1443                gps_latitude REAL,
1444                gps_longitude REAL,
1445                is_trashed BOOLEAN DEFAULT FALSE
1446            );
1447            CREATE TABLE album_suggestions (
1448                id INTEGER PRIMARY KEY,
1449                kind TEXT NOT NULL,
1450                title TEXT NOT NULL,
1451                photo_ids_json TEXT NOT NULL,
1452                cover_photo_id INTEGER,
1453                fingerprint TEXT NOT NULL,
1454                status TEXT NOT NULL,
1455                seen_count INTEGER NOT NULL DEFAULT 0,
1456                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
1457            );
1458            INSERT INTO photos
1459                (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1460            VALUES
1461                (1, 'Goa', 'India', '2024-01-01T10:00:00Z', 15.2993, 74.1240, FALSE),
1462                (2, 'Goa', 'India', '2024-01-02T10:00:00Z', 15.2993, 74.1240, FALSE);
1463            "#,
1464        )
1465        .unwrap();
1466
1467        let cancel = AtomicBool::new(true);
1468        let (suggestions, _diag) =
1469            detect_suggestions_with_diagnostics_cancel(&conn, None, Some(&cancel));
1470        let count: i64 = conn
1471            .query_row("SELECT COUNT(*) FROM album_suggestions", [], |row| {
1472                row.get(0)
1473            })
1474            .unwrap();
1475
1476        assert!(suggestions.is_empty());
1477        assert_eq!(count, 0);
1478    }
1479}