Skip to main content

aranet_store/
store.rs

1//! Main store implementation.
2//!
3//! # SQLite Concurrency Model
4//!
5//! This store uses SQLite with WAL (Write-Ahead Logging) mode enabled for improved
6//! concurrent read performance. Key concurrency characteristics:
7//!
8//! - **Multiple readers**: WAL mode allows multiple simultaneous read transactions
9//! - **Single writer**: Only one write transaction can be active at a time
10//! - **Non-blocking reads**: Read operations don't block write operations and vice versa
11//!
12//! ## Thread Safety
13//!
14//! The `Store` struct is **not thread-safe** by itself. When using `Store` in a
15//! multi-threaded context (e.g., `aranet-service`), wrap it in a `Mutex` or similar:
16//!
17//! ```ignore
18//! use tokio::sync::Mutex;
19//! let store = Mutex::new(Store::open_default()?);
20//!
21//! // Access the store
22//! let guard = store.lock().await;
23//! let devices = guard.list_devices()?;
24//! ```
25//!
26//! ## Performance Considerations
27//!
28//! - For high-concurrency scenarios, consider keeping lock hold times short
29//! - Batch operations (like `insert_history`) are more efficient than individual inserts
30//! - Query operations with indexes (`device_id`, `timestamp`) are optimized
31//!
32//! ## Database Location
33//!
34//! The default database path is platform-specific:
35//! - **Linux**: `~/.local/share/aranet/data.db`
36//! - **macOS**: `~/Library/Application Support/aranet/data.db`
37//! - **Windows**: `C:\Users\<user>\AppData\Local\aranet\data.db`
38
39use std::path::{Path, PathBuf};
40
41use rusqlite::{Connection, OptionalExtension};
42use time::OffsetDateTime;
43use tracing::{debug, info, warn};
44
45use aranet_types::{CurrentReading, DeviceInfo, DeviceType, HistoryRecord, Status};
46
47/// Safely convert a Unix timestamp to OffsetDateTime.
48///
49/// Returns UNIX_EPOCH as a sentinel for clearly invalid timestamps (zero,
50/// negative, or out of range). Downstream code can check for epoch
51/// timestamps to detect corrupt rows. A warning is always logged for
52/// invalid values to aid in database integrity diagnosis.
53fn timestamp_from_unix(ts: i64) -> OffsetDateTime {
54    match OffsetDateTime::from_unix_timestamp(ts) {
55        Ok(dt) if ts > 0 => dt,
56        Ok(_) => {
57            warn!(
58                "Unexpected zero/negative timestamp {ts} in database, \
59                 returning epoch sentinel. Run 'aranet cache check' to inspect database integrity."
60            );
61            OffsetDateTime::UNIX_EPOCH
62        }
63        Err(_) => {
64            warn!(
65                "Corrupted timestamp {ts} in database (out of representable range), \
66                 returning epoch sentinel. Run 'aranet cache check' to inspect database integrity."
67            );
68            OffsetDateTime::UNIX_EPOCH
69        }
70    }
71}
72
73/// Convert an `i64` from the database to a `u32` radon value, logging a
74/// warning if the value is negative instead of silently dropping it.
75fn radon_from_i64(v: i64, context: &str) -> Option<u32> {
76    match u32::try_from(v) {
77        Ok(val) => Some(val),
78        Err(_) => {
79            warn!("Invalid radon value {v} in {context} (expected non-negative u32)");
80            None
81        }
82    }
83}
84
85use crate::error::{Error, Result};
86use crate::models::{StoredDevice, StoredHistoryRecord, StoredReading, SyncState};
87use crate::queries::{HistoryQuery, ReadingQuery};
88use crate::schema;
89
90/// SQLite-based store for Aranet sensor data.
91///
92/// `Store` provides persistent storage for sensor readings, history records,
93/// and device metadata using SQLite. It supports:
94///
95/// - **Device management**: Track multiple Aranet devices with metadata
96/// - **Current readings**: Store real-time sensor data with timestamps
97/// - **History records**: Cache device history to avoid re-downloading
98/// - **Incremental sync**: Track sync state for efficient history updates
99/// - **Export/Import**: CSV and JSON formats for data portability
100///
101/// # Thread Safety
102///
103/// `Store` is **not thread-safe**. For concurrent access (e.g., in `aranet-service`),
104/// wrap it in a `Mutex`:
105///
106/// ```ignore
107/// use std::sync::Arc;
108/// use tokio::sync::Mutex;
109/// use aranet_store::Store;
110///
111/// let store = Arc::new(Mutex::new(Store::open_default()?));
112///
113/// // In async context:
114/// let guard = store.lock().await;
115/// let devices = guard.list_devices()?;
116/// ```
117///
118/// # Example
119///
120/// ```no_run
121/// use aranet_store::{Store, ReadingQuery, HistoryQuery};
122/// use aranet_types::CurrentReading;
123///
124/// // Open the default database
125/// let store = Store::open_default()?;
126///
127/// // Store a reading
128/// let reading = CurrentReading::default();
129/// store.insert_reading("Aranet4 17C3C", &reading)?;
130///
131/// // Query readings
132/// let query = ReadingQuery::new().device("Aranet4 17C3C").limit(10);
133/// let readings = store.query_readings(&query)?;
134///
135/// // Export history to CSV
136/// let csv = store.export_history_csv(&HistoryQuery::new())?;
137/// # Ok::<(), aranet_store::Error>(())
138/// ```
139pub struct Store {
140    conn: Connection,
141    path: Option<PathBuf>,
142}
143
144impl Store {
145    /// Open or create a database at the given path.
146    ///
147    /// Creates parent directories if they don't exist. The database is
148    /// initialized with WAL mode for better concurrent read performance.
149    ///
150    /// # Arguments
151    ///
152    /// * `path` - Path to the SQLite database file
153    ///
154    /// # Example
155    ///
156    /// ```no_run
157    /// use aranet_store::Store;
158    ///
159    /// let store = Store::open("/path/to/my/aranet.db")?;
160    /// # Ok::<(), aranet_store::Error>(())
161    /// ```
162    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
163        let path = path.as_ref();
164
165        // Create parent directories if needed (create_dir_all is idempotent)
166        if let Some(parent) = path.parent() {
167            std::fs::create_dir_all(parent).map_err(|e| Error::CreateDirectory {
168                path: parent.to_path_buf(),
169                source: e,
170            })?;
171        }
172
173        debug!("Opening database at {}", path.display());
174        let conn = Connection::open(path)?;
175
176        // Enable foreign keys and WAL mode for better performance
177        conn.execute_batch(
178            "PRAGMA foreign_keys = ON;
179             PRAGMA journal_mode = WAL;
180             PRAGMA synchronous = NORMAL;",
181        )?;
182
183        // Initialize schema
184        schema::initialize(&conn)?;
185
186        Ok(Self {
187            conn,
188            path: Some(path.to_path_buf()),
189        })
190    }
191
192    /// Open the database at the platform-specific default location.
193    ///
194    /// Default paths by platform:
195    /// - **Linux**: `~/.local/share/aranet/data.db`
196    /// - **macOS**: `~/Library/Application Support/aranet/data.db`
197    /// - **Windows**: `C:\Users\<user>\AppData\Local\aranet\data.db`
198    ///
199    /// # Example
200    ///
201    /// ```no_run
202    /// use aranet_store::Store;
203    ///
204    /// let store = Store::open_default()?;
205    /// # Ok::<(), aranet_store::Error>(())
206    /// ```
207    pub fn open_default() -> Result<Self> {
208        Self::open(crate::default_db_path())
209    }
210
211    /// Open an in-memory database.
212    ///
213    /// Useful for testing or temporary storage. Data is lost when the
214    /// `Store` is dropped.
215    ///
216    /// # Example
217    ///
218    /// ```
219    /// use aranet_store::Store;
220    ///
221    /// let store = Store::open_in_memory()?;
222    /// // Use for testing...
223    /// # Ok::<(), aranet_store::Error>(())
224    /// ```
225    pub fn open_in_memory() -> Result<Self> {
226        let conn = Connection::open_in_memory()?;
227        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
228        schema::initialize(&conn)?;
229        Ok(Self { conn, path: None })
230    }
231
232    /// Return the database path for file-backed stores.
233    ///
234    /// In-memory stores return `None`.
235    pub fn database_path(&self) -> Option<&Path> {
236        self.path.as_deref()
237    }
238
239    // === Device operations ===
240
241    /// Get or create a device entry, updating timestamps.
242    ///
243    /// If the device exists, updates its `last_seen` timestamp and optionally
244    /// the name. If it doesn't exist, creates a new entry with the current time
245    /// as both `first_seen` and `last_seen`.
246    ///
247    /// # Arguments
248    ///
249    /// * `device_id` - Unique identifier for the device (typically BLE address)
250    /// * `name` - Optional human-readable name for the device
251    ///
252    /// # Returns
253    ///
254    /// The device record after insert/update.
255    ///
256    /// # Example
257    ///
258    /// ```
259    /// use aranet_store::Store;
260    ///
261    /// let store = Store::open_in_memory()?;
262    /// let device = store.upsert_device("Aranet4 17C3C", Some("Kitchen"))?;
263    /// assert_eq!(device.name, Some("Kitchen".to_string()));
264    /// # Ok::<(), aranet_store::Error>(())
265    /// ```
266    pub fn upsert_device(&self, device_id: &str, name: Option<&str>) -> Result<StoredDevice> {
267        let now = OffsetDateTime::now_utc().unix_timestamp();
268
269        self.conn.execute(
270            "INSERT INTO devices (id, name, first_seen, last_seen) VALUES (?1, ?2, ?3, ?3)
271             ON CONFLICT(id) DO UPDATE SET 
272                name = COALESCE(?2, name),
273                last_seen = ?3",
274            rusqlite::params![device_id, name, now],
275        )?;
276
277        self.get_device(device_id)?
278            .ok_or_else(|| Error::DeviceNotFound(device_id.to_string()))
279    }
280
281    /// Update device metadata (name and type).
282    ///
283    /// This is a simpler version of `update_device_info` for when you only have
284    /// basic device information (e.g., from BLE advertisement or connection).
285    pub fn update_device_metadata(
286        &self,
287        device_id: &str,
288        name: Option<&str>,
289        device_type: Option<DeviceType>,
290    ) -> Result<()> {
291        let device_type_str = device_type.map(|dt| format!("{:?}", dt));
292        let now = OffsetDateTime::now_utc().unix_timestamp();
293
294        self.conn.execute(
295            "UPDATE devices SET
296                name = COALESCE(?2, name),
297                device_type = COALESCE(?3, device_type),
298                last_seen = ?4
299             WHERE id = ?1",
300            rusqlite::params![device_id, name, device_type_str, now],
301        )?;
302
303        Ok(())
304    }
305
306    /// Update device info from DeviceInfo.
307    ///
308    /// Device type is automatically inferred from the model name using
309    /// `DeviceType::from_name()`, which handles all known Aranet device naming patterns.
310    pub fn update_device_info(&self, device_id: &str, info: &DeviceInfo) -> Result<()> {
311        // Use the shared DeviceType::from_name() for consistent device type detection
312        let device_type = DeviceType::from_name(&info.model).map(|dt| format!("{:?}", dt));
313
314        let name = if info.name.is_empty() {
315            None
316        } else {
317            Some(&info.name)
318        };
319
320        self.conn.execute(
321            "UPDATE devices SET
322                name = COALESCE(?2, name),
323                device_type = COALESCE(?3, device_type),
324                serial = COALESCE(?4, serial),
325                firmware = COALESCE(?5, firmware),
326                hardware = COALESCE(?6, hardware),
327                last_seen = ?7
328             WHERE id = ?1",
329            rusqlite::params![
330                device_id,
331                name,
332                device_type,
333                &info.serial,
334                &info.firmware,
335                &info.hardware,
336                OffsetDateTime::now_utc().unix_timestamp()
337            ],
338        )?;
339
340        Ok(())
341    }
342
343    /// Get a device by its unique identifier.
344    ///
345    /// # Arguments
346    ///
347    /// * `device_id` - The device identifier to look up
348    ///
349    /// # Returns
350    ///
351    /// `Some(StoredDevice)` if found, `None` if the device doesn't exist.
352    ///
353    /// # Example
354    ///
355    /// ```
356    /// use aranet_store::Store;
357    ///
358    /// let store = Store::open_in_memory()?;
359    /// store.upsert_device("Aranet4 17C3C", Some("Kitchen"))?;
360    ///
361    /// if let Some(device) = store.get_device("Aranet4 17C3C")? {
362    ///     println!("Found device: {:?}", device.name);
363    /// }
364    /// # Ok::<(), aranet_store::Error>(())
365    /// ```
366    pub fn get_device(&self, device_id: &str) -> Result<Option<StoredDevice>> {
367        let mut stmt = self.conn.prepare(
368            "SELECT id, name, device_type, serial, firmware, hardware, first_seen, last_seen 
369             FROM devices WHERE id = ?",
370        )?;
371
372        let device = stmt
373            .query_row([device_id], |row| {
374                Ok(StoredDevice {
375                    id: row.get(0)?,
376                    name: row.get(1)?,
377                    device_type: row
378                        .get::<_, Option<String>>(2)?
379                        .and_then(|s| parse_device_type(&s)),
380                    serial: row.get(3)?,
381                    firmware: row.get(4)?,
382                    hardware: row.get(5)?,
383                    first_seen: timestamp_from_unix(row.get(6)?),
384                    last_seen: timestamp_from_unix(row.get(7)?),
385                })
386            })
387            .optional()?;
388
389        Ok(device)
390    }
391
392    /// List all known devices, ordered by most recently seen first.
393    ///
394    /// # Returns
395    ///
396    /// A vector of all stored devices, sorted by `last_seen` descending.
397    ///
398    /// # Example
399    ///
400    /// ```
401    /// use aranet_store::Store;
402    ///
403    /// let store = Store::open_in_memory()?;
404    /// store.upsert_device("device-1", Some("Kitchen"))?;
405    /// store.upsert_device("device-2", Some("Bedroom"))?;
406    ///
407    /// let devices = store.list_devices()?;
408    /// for device in devices {
409    ///     println!("{}: {:?}", device.id, device.name);
410    /// }
411    /// # Ok::<(), aranet_store::Error>(())
412    /// ```
413    pub fn list_devices(&self) -> Result<Vec<StoredDevice>> {
414        let mut stmt = self.conn.prepare(
415            "SELECT id, name, device_type, serial, firmware, hardware, first_seen, last_seen 
416             FROM devices ORDER BY last_seen DESC",
417        )?;
418
419        let devices = stmt
420            .query_map([], |row| {
421                Ok(StoredDevice {
422                    id: row.get(0)?,
423                    name: row.get(1)?,
424                    device_type: row
425                        .get::<_, Option<String>>(2)?
426                        .and_then(|s| parse_device_type(&s)),
427                    serial: row.get(3)?,
428                    firmware: row.get(4)?,
429                    hardware: row.get(5)?,
430                    first_seen: timestamp_from_unix(row.get(6)?),
431                    last_seen: timestamp_from_unix(row.get(7)?),
432                })
433            })?
434            .collect::<std::result::Result<Vec<_>, _>>()?;
435
436        Ok(devices)
437    }
438
439    /// Delete a device and all associated data (readings, history, sync state).
440    ///
441    /// All deletions are performed within a transaction to ensure atomicity.
442    /// Returns true if the device was deleted, false if it didn't exist.
443    pub fn delete_device(&self, device_id: &str) -> Result<bool> {
444        let tx = self.conn.unchecked_transaction()?;
445
446        // Delete in order: history, readings, sync_state, device
447        tx.execute(
448            "DELETE FROM history WHERE device_id = ?1",
449            rusqlite::params![device_id],
450        )?;
451
452        tx.execute(
453            "DELETE FROM readings WHERE device_id = ?1",
454            rusqlite::params![device_id],
455        )?;
456
457        tx.execute(
458            "DELETE FROM sync_state WHERE device_id = ?1",
459            rusqlite::params![device_id],
460        )?;
461
462        let rows_deleted = tx.execute(
463            "DELETE FROM devices WHERE id = ?1",
464            rusqlite::params![device_id],
465        )?;
466
467        tx.commit()?;
468
469        Ok(rows_deleted > 0)
470    }
471
472    /// Delete history records older than the given timestamp.
473    ///
474    /// Returns the number of records deleted.
475    pub fn prune_history(&self, older_than: OffsetDateTime) -> Result<u64> {
476        let ts = older_than.unix_timestamp();
477        let deleted = self.conn.execute(
478            "DELETE FROM history WHERE timestamp < ?1",
479            rusqlite::params![ts],
480        )?;
481        Ok(deleted as u64)
482    }
483
484    /// Delete readings older than the given timestamp.
485    ///
486    /// Returns the number of records deleted.
487    pub fn prune_readings(&self, older_than: OffsetDateTime) -> Result<u64> {
488        let ts = older_than.unix_timestamp();
489        let deleted = self.conn.execute(
490            "DELETE FROM readings WHERE captured_at < ?1",
491            rusqlite::params![ts],
492        )?;
493        Ok(deleted as u64)
494    }
495
496    /// Reclaim unused disk space after deletions.
497    pub fn vacuum(&self) -> Result<()> {
498        self.conn.execute_batch("VACUUM;")?;
499        Ok(())
500    }
501}
502
503fn parse_device_type(s: &str) -> Option<DeviceType> {
504    match s {
505        "Aranet4" => Some(DeviceType::Aranet4),
506        "Aranet2" => Some(DeviceType::Aranet2),
507        "AranetRadon" => Some(DeviceType::AranetRadon),
508        "AranetRadiation" => Some(DeviceType::AranetRadiation),
509        _ => None,
510    }
511}
512
513fn parse_status(s: &str) -> Status {
514    match s {
515        "Green" => Status::Green,
516        "Yellow" => Status::Yellow,
517        "Red" => Status::Red,
518        "Error" => Status::Error,
519        _ => {
520            tracing::warn!("Unknown status value '{}', defaulting to Error", s);
521            Status::Error
522        }
523    }
524}
525
526// Reading operations
527impl Store {
528    /// Insert a current reading from a device.
529    ///
530    /// Automatically creates the device entry if it doesn't exist. The reading
531    /// is stored with its `captured_at` timestamp, or the current time if not set.
532    ///
533    /// # Arguments
534    ///
535    /// * `device_id` - The device that produced this reading
536    /// * `reading` - The sensor reading to store
537    ///
538    /// # Returns
539    ///
540    /// The database row ID of the inserted reading.
541    ///
542    /// # Example
543    ///
544    /// ```
545    /// use aranet_store::Store;
546    /// use aranet_types::{CurrentReading, Status};
547    ///
548    /// let store = Store::open_in_memory()?;
549    /// let reading = CurrentReading {
550    ///     co2: 800,
551    ///     temperature: 22.5,
552    ///     pressure: 1013.0,
553    ///     humidity: 45,
554    ///     battery: 85,
555    ///     status: Status::Green,
556    ///     ..Default::default()
557    /// };
558    ///
559    /// let row_id = store.insert_reading("Aranet4 17C3C", &reading)?;
560    /// # Ok::<(), aranet_store::Error>(())
561    /// ```
562    pub fn insert_reading(&self, device_id: &str, reading: &CurrentReading) -> Result<i64> {
563        // Ensure device exists
564        self.upsert_device(device_id, None)?;
565
566        let captured_at = reading
567            .captured_at
568            .unwrap_or_else(OffsetDateTime::now_utc)
569            .unix_timestamp();
570
571        self.conn.execute(
572            "INSERT INTO readings (device_id, captured_at, co2, temperature, pressure,
573             humidity, battery, status, radon, radiation_rate, radiation_total,
574             radon_avg_24h, radon_avg_7d, radon_avg_30d)
575             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
576            rusqlite::params![
577                device_id,
578                captured_at,
579                reading.co2,
580                reading.temperature,
581                reading.pressure,
582                reading.humidity,
583                reading.battery,
584                format!("{:?}", reading.status),
585                reading.radon,
586                reading.radiation_rate,
587                reading.radiation_total,
588                reading.radon_avg_24h,
589                reading.radon_avg_7d,
590                reading.radon_avg_30d,
591            ],
592        )?;
593
594        Ok(self.conn.last_insert_rowid())
595    }
596
597    /// Query readings with optional filters.
598    ///
599    /// Use [`ReadingQuery`] to build queries with device, time range,
600    /// pagination, and ordering filters.
601    ///
602    /// # Arguments
603    ///
604    /// * `query` - Query parameters built using [`ReadingQuery`]
605    ///
606    /// # Example
607    ///
608    /// ```
609    /// use aranet_store::{Store, ReadingQuery};
610    /// use time::{OffsetDateTime, Duration};
611    ///
612    /// let store = Store::open_in_memory()?;
613    ///
614    /// // Query last 24 hours for a specific device
615    /// let yesterday = OffsetDateTime::now_utc() - Duration::hours(24);
616    /// let query = ReadingQuery::new()
617    ///     .device("Aranet4 17C3C")
618    ///     .since(yesterday)
619    ///     .limit(100);
620    ///
621    /// let readings = store.query_readings(&query)?;
622    /// for reading in readings {
623    ///     println!("CO2: {} ppm at {}", reading.co2, reading.captured_at);
624    /// }
625    /// # Ok::<(), aranet_store::Error>(())
626    /// ```
627    pub fn query_readings(&self, query: &ReadingQuery) -> Result<Vec<StoredReading>> {
628        let sql = query.build_sql();
629        let (_, params) = query.build_where();
630
631        debug!("Executing query: {}", sql);
632
633        let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
634
635        let mut stmt = self.conn.prepare(&sql)?;
636        let readings = stmt
637            .query_map(params_ref.as_slice(), |row| {
638                Ok(StoredReading {
639                    id: row.get(0)?,
640                    device_id: row.get(1)?,
641                    captured_at: timestamp_from_unix(row.get(2)?),
642                    co2: u16::try_from(row.get::<_, i64>(3)?).unwrap_or_else(|e| {
643                        warn!("Invalid co2 value in database: {e}");
644                        0
645                    }),
646                    temperature: row.get(4)?,
647                    pressure: row.get(5)?,
648                    humidity: u8::try_from(row.get::<_, i64>(6)?).unwrap_or_else(|e| {
649                        warn!("Invalid humidity value in database: {e}");
650                        0
651                    }),
652                    battery: u8::try_from(row.get::<_, i64>(7)?).unwrap_or_else(|e| {
653                        warn!("Invalid battery value in database: {e}");
654                        0
655                    }),
656                    status: parse_status(&row.get::<_, String>(8)?),
657                    radon: row
658                        .get::<_, Option<i64>>(9)?
659                        .and_then(|v| radon_from_i64(v, "readings")),
660                    radiation_rate: row.get(10)?,
661                    radiation_total: row.get(11)?,
662                    radon_avg_24h: row
663                        .get::<_, Option<i64>>(12)?
664                        .and_then(|v| radon_from_i64(v, "readings")),
665                    radon_avg_7d: row
666                        .get::<_, Option<i64>>(13)?
667                        .and_then(|v| radon_from_i64(v, "readings")),
668                    radon_avg_30d: row
669                        .get::<_, Option<i64>>(14)?
670                        .and_then(|v| radon_from_i64(v, "readings")),
671                })
672            })?
673            .collect::<std::result::Result<Vec<_>, _>>()?;
674
675        Ok(readings)
676    }
677
678    /// Get the most recent reading for a device.
679    ///
680    /// Convenience method equivalent to `query_readings` with `limit(1)`.
681    ///
682    /// # Arguments
683    ///
684    /// * `device_id` - The device to get the latest reading for
685    ///
686    /// # Returns
687    ///
688    /// The most recent reading, or `None` if no readings exist for this device.
689    ///
690    /// # Example
691    ///
692    /// ```
693    /// use aranet_store::Store;
694    ///
695    /// let store = Store::open_in_memory()?;
696    ///
697    /// if let Some(reading) = store.get_latest_reading("Aranet4 17C3C")? {
698    ///     println!("Latest CO2: {} ppm", reading.co2);
699    /// }
700    /// # Ok::<(), aranet_store::Error>(())
701    /// ```
702    pub fn get_latest_reading(&self, device_id: &str) -> Result<Option<StoredReading>> {
703        let query = ReadingQuery::new().device(device_id).limit(1);
704        let mut readings = self.query_readings(&query)?;
705        Ok(readings.pop())
706    }
707
708    /// List each device together with its latest stored reading.
709    ///
710    /// Devices without readings are omitted. Results are ordered by device
711    /// `last_seen` descending to match [`Store::list_devices`].
712    pub fn list_latest_readings(&self) -> Result<Vec<(StoredDevice, StoredReading)>> {
713        let mut stmt = self.conn.prepare(
714            "SELECT
715                d.id, d.name, d.device_type, d.serial, d.firmware, d.hardware, d.first_seen, d.last_seen,
716                r.id, r.device_id, r.captured_at, r.co2, r.temperature, r.pressure, r.humidity, r.battery,
717                r.status, r.radon, r.radiation_rate, r.radiation_total, r.radon_avg_24h, r.radon_avg_7d, r.radon_avg_30d
718             FROM devices d
719             JOIN readings r ON r.id = (
720                SELECT latest.id
721                FROM readings latest
722                WHERE latest.device_id = d.id
723                ORDER BY latest.captured_at DESC, latest.id DESC
724                LIMIT 1
725             )
726             ORDER BY d.last_seen DESC",
727        )?;
728
729        let rows = stmt
730            .query_map([], |row| {
731                let device = StoredDevice {
732                    id: row.get(0)?,
733                    name: row.get(1)?,
734                    device_type: row
735                        .get::<_, Option<String>>(2)?
736                        .and_then(|s| parse_device_type(&s)),
737                    serial: row.get(3)?,
738                    firmware: row.get(4)?,
739                    hardware: row.get(5)?,
740                    first_seen: timestamp_from_unix(row.get(6)?),
741                    last_seen: timestamp_from_unix(row.get(7)?),
742                };
743                let reading = StoredReading {
744                    id: row.get(8)?,
745                    device_id: row.get(9)?,
746                    captured_at: timestamp_from_unix(row.get(10)?),
747                    co2: u16::try_from(row.get::<_, i64>(11)?).unwrap_or(0),
748                    temperature: row.get(12)?,
749                    pressure: row.get(13)?,
750                    humidity: u8::try_from(row.get::<_, i64>(14)?).unwrap_or(0),
751                    battery: u8::try_from(row.get::<_, i64>(15)?).unwrap_or(0),
752                    status: parse_status(&row.get::<_, String>(16)?),
753                    radon: row
754                        .get::<_, Option<i64>>(17)?
755                        .and_then(|v| radon_from_i64(v, "latest_readings")),
756                    radiation_rate: row.get(18)?,
757                    radiation_total: row.get(19)?,
758                    radon_avg_24h: row
759                        .get::<_, Option<i64>>(20)?
760                        .and_then(|v| radon_from_i64(v, "latest_readings")),
761                    radon_avg_7d: row
762                        .get::<_, Option<i64>>(21)?
763                        .and_then(|v| radon_from_i64(v, "latest_readings")),
764                    radon_avg_30d: row
765                        .get::<_, Option<i64>>(22)?
766                        .and_then(|v| radon_from_i64(v, "latest_readings")),
767                };
768
769                Ok((device, reading))
770            })?
771            .collect::<std::result::Result<Vec<_>, _>>()?;
772
773        Ok(rows)
774    }
775
776    /// Count total readings, optionally filtered by device.
777    ///
778    /// # Arguments
779    ///
780    /// * `device_id` - If `Some`, count only readings for this device.
781    ///   If `None`, count all readings across all devices.
782    ///
783    /// # Example
784    ///
785    /// ```
786    /// use aranet_store::Store;
787    ///
788    /// let store = Store::open_in_memory()?;
789    ///
790    /// // Count all readings
791    /// let total = store.count_readings(None)?;
792    ///
793    /// // Count for specific device
794    /// let device_count = store.count_readings(Some("Aranet4 17C3C"))?;
795    /// # Ok::<(), aranet_store::Error>(())
796    /// ```
797    pub fn count_readings(&self, device_id: Option<&str>) -> Result<u64> {
798        let count: i64 = match device_id {
799            Some(id) => self.conn.query_row(
800                "SELECT COUNT(*) FROM readings WHERE device_id = ?",
801                [id],
802                |row| row.get(0),
803            )?,
804            None => self
805                .conn
806                .query_row("SELECT COUNT(*) FROM readings", [], |row| row.get(0))?,
807        };
808
809        Ok(count as u64)
810    }
811}
812
813// History operations
814impl Store {
815    /// Insert history records with automatic deduplication.
816    ///
817    /// Records are deduplicated by `(device_id, timestamp)` - if a record with
818    /// the same timestamp already exists for this device, it is skipped.
819    /// This allows safe re-syncing without creating duplicates.
820    ///
821    /// # Arguments
822    ///
823    /// * `device_id` - The device these history records belong to
824    /// * `records` - Slice of history records to insert
825    ///
826    /// # Returns
827    ///
828    /// The number of records actually inserted (excluding duplicates).
829    ///
830    /// # Example
831    ///
832    /// ```
833    /// use aranet_store::Store;
834    /// use aranet_types::HistoryRecord;
835    /// use time::OffsetDateTime;
836    ///
837    /// let store = Store::open_in_memory()?;
838    ///
839    /// let records = vec![
840    ///     HistoryRecord {
841    ///         timestamp: OffsetDateTime::now_utc(),
842    ///         co2: 800,
843    ///         temperature: 22.5,
844    ///         pressure: 1013.0,
845    ///         humidity: 45,
846    ///         radon: None,
847    ///         radiation_rate: None,
848    ///         radiation_total: None,
849    ///     },
850    /// ];
851    ///
852    /// let inserted = store.insert_history("Aranet4 17C3C", &records)?;
853    /// println!("Inserted {} new records", inserted);
854    /// # Ok::<(), aranet_store::Error>(())
855    /// ```
856    pub fn insert_history(&self, device_id: &str, records: &[HistoryRecord]) -> Result<usize> {
857        // Ensure device exists
858        self.upsert_device(device_id, None)?;
859
860        let tx = self.conn.unchecked_transaction()?;
861        let synced_at = OffsetDateTime::now_utc().unix_timestamp();
862        let mut inserted = 0;
863
864        for record in records {
865            let result = tx.execute(
866                "INSERT OR IGNORE INTO history (device_id, timestamp, synced_at, co2,
867                 temperature, pressure, humidity, radon, radiation_rate, radiation_total)
868                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
869                rusqlite::params![
870                    device_id,
871                    record.timestamp.unix_timestamp(),
872                    synced_at,
873                    record.co2,
874                    record.temperature,
875                    record.pressure,
876                    record.humidity,
877                    record.radon,
878                    record.radiation_rate,
879                    record.radiation_total,
880                ],
881            )?;
882            inserted += result;
883        }
884
885        tx.commit()?;
886
887        let skipped = records.len() - inserted;
888        if skipped > 0 {
889            info!(
890                "Inserted {} new history records for {} ({} duplicates skipped)",
891                inserted, device_id, skipped
892            );
893        } else {
894            info!(
895                "Inserted {} new history records for {}",
896                inserted, device_id
897            );
898        }
899        Ok(inserted)
900    }
901
902    /// Query history records with optional filters.
903    ///
904    /// Use [`HistoryQuery`] to build queries with device, time range,
905    /// pagination, and ordering filters.
906    ///
907    /// # Arguments
908    ///
909    /// * `query` - Query parameters built using [`HistoryQuery`]
910    ///
911    /// # Example
912    ///
913    /// ```
914    /// use aranet_store::{Store, HistoryQuery};
915    /// use time::{OffsetDateTime, Duration};
916    ///
917    /// let store = Store::open_in_memory()?;
918    ///
919    /// // Query last week's history for a device
920    /// let week_ago = OffsetDateTime::now_utc() - Duration::days(7);
921    /// let query = HistoryQuery::new()
922    ///     .device("Aranet4 17C3C")
923    ///     .since(week_ago)
924    ///     .oldest_first();
925    ///
926    /// let records = store.query_history(&query)?;
927    /// # Ok::<(), aranet_store::Error>(())
928    /// ```
929    pub fn query_history(&self, query: &HistoryQuery) -> Result<Vec<StoredHistoryRecord>> {
930        let sql = query.build_sql();
931        let (_, params) = query.build_where();
932        let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
933
934        let mut stmt = self.conn.prepare(&sql)?;
935        let records = stmt
936            .query_map(params_ref.as_slice(), |row| {
937                Ok(StoredHistoryRecord {
938                    id: row.get(0)?,
939                    device_id: row.get(1)?,
940                    timestamp: timestamp_from_unix(row.get(2)?),
941                    synced_at: timestamp_from_unix(row.get(3)?),
942                    co2: u16::try_from(row.get::<_, i64>(4)?).unwrap_or_else(|e| {
943                        warn!("Invalid co2 value in history: {e}");
944                        0
945                    }),
946                    temperature: row.get(5)?,
947                    pressure: row.get(6)?,
948                    humidity: u8::try_from(row.get::<_, i64>(7)?).unwrap_or_else(|e| {
949                        warn!("Invalid humidity value in history: {e}");
950                        0
951                    }),
952                    radon: row
953                        .get::<_, Option<i64>>(8)?
954                        .and_then(|v| radon_from_i64(v, "history")),
955                    radiation_rate: row.get(9)?,
956                    radiation_total: row.get(10)?,
957                })
958            })?
959            .collect::<std::result::Result<Vec<_>, _>>()?;
960
961        Ok(records)
962    }
963
964    /// Count total history records, optionally filtered by device.
965    ///
966    /// # Arguments
967    ///
968    /// * `device_id` - If `Some`, count only records for this device.
969    ///   If `None`, count all records across all devices.
970    ///
971    /// # Example
972    ///
973    /// ```
974    /// use aranet_store::Store;
975    ///
976    /// let store = Store::open_in_memory()?;
977    ///
978    /// // Count all history records
979    /// let total = store.count_history(None)?;
980    ///
981    /// // Count for specific device
982    /// let device_count = store.count_history(Some("Aranet4 17C3C"))?;
983    /// # Ok::<(), aranet_store::Error>(())
984    /// ```
985    pub fn count_history(&self, device_id: Option<&str>) -> Result<u64> {
986        let count: i64 = match device_id {
987            Some(id) => self.conn.query_row(
988                "SELECT COUNT(*) FROM history WHERE device_id = ?",
989                [id],
990                |row| row.get(0),
991            )?,
992            None => self
993                .conn
994                .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))?,
995        };
996
997        Ok(count as u64)
998    }
999}
1000
1001// Sync state operations
1002impl Store {
1003    /// Get the sync state for a device.
1004    ///
1005    /// Sync state tracks the last downloaded history index and total readings,
1006    /// enabling incremental history downloads instead of re-downloading everything.
1007    ///
1008    /// # Arguments
1009    ///
1010    /// * `device_id` - The device to get sync state for
1011    ///
1012    /// # Returns
1013    ///
1014    /// The sync state if any history has been synced, `None` for new devices.
1015    ///
1016    /// # Example
1017    ///
1018    /// ```
1019    /// use aranet_store::Store;
1020    ///
1021    /// let store = Store::open_in_memory()?;
1022    /// store.upsert_device("Aranet4 17C3C", None)?;
1023    ///
1024    /// // Initially no sync state
1025    /// let state = store.get_sync_state("Aranet4 17C3C")?;
1026    /// assert!(state.is_none());
1027    /// # Ok::<(), aranet_store::Error>(())
1028    /// ```
1029    pub fn get_sync_state(&self, device_id: &str) -> Result<Option<SyncState>> {
1030        let mut stmt = self.conn.prepare(
1031            "SELECT device_id, last_history_index, total_readings, last_sync_at
1032             FROM sync_state WHERE device_id = ?",
1033        )?;
1034
1035        let state = stmt
1036            .query_row([device_id], |row| {
1037                Ok(SyncState {
1038                    device_id: row.get(0)?,
1039                    last_history_index: row
1040                        .get::<_, Option<i64>>(1)?
1041                        .and_then(|v| u16::try_from(v).ok()),
1042                    total_readings: row
1043                        .get::<_, Option<i64>>(2)?
1044                        .and_then(|v| u16::try_from(v).ok()),
1045                    last_sync_at: row.get::<_, Option<i64>>(3)?.map(timestamp_from_unix),
1046                })
1047            })
1048            .optional()?;
1049
1050        Ok(state)
1051    }
1052
1053    /// Update sync state after a successful history download.
1054    ///
1055    /// Call this after downloading history records to track progress. The next
1056    /// sync can then use [`calculate_sync_start`](Self::calculate_sync_start) to
1057    /// determine which records to download.
1058    ///
1059    /// # Arguments
1060    ///
1061    /// * `device_id` - The device that was synced
1062    /// * `last_index` - The highest history index that was downloaded (1-based)
1063    /// * `total_readings` - Total readings on the device at sync time
1064    ///
1065    /// # Example
1066    ///
1067    /// ```
1068    /// use aranet_store::Store;
1069    ///
1070    /// let store = Store::open_in_memory()?;
1071    /// store.upsert_device("Aranet4 17C3C", None)?;
1072    ///
1073    /// // After downloading all 500 history records
1074    /// store.update_sync_state("Aranet4 17C3C", 500, 500)?;
1075    ///
1076    /// // Verify sync state was saved
1077    /// let state = store.get_sync_state("Aranet4 17C3C")?.unwrap();
1078    /// assert_eq!(state.last_history_index, Some(500));
1079    /// # Ok::<(), aranet_store::Error>(())
1080    /// ```
1081    pub fn update_sync_state(
1082        &self,
1083        device_id: &str,
1084        last_index: u16,
1085        total_readings: u16,
1086    ) -> Result<()> {
1087        let now = OffsetDateTime::now_utc().unix_timestamp();
1088
1089        self.conn.execute(
1090            "INSERT INTO sync_state (device_id, last_history_index, total_readings, last_sync_at)
1091             VALUES (?1, ?2, ?3, ?4)
1092             ON CONFLICT(device_id) DO UPDATE SET
1093                last_history_index = ?2,
1094                total_readings = ?3,
1095                last_sync_at = ?4",
1096            rusqlite::params![device_id, last_index, total_readings, now],
1097        )?;
1098
1099        debug!(
1100            "Updated sync state for {}: index={}, total={}",
1101            device_id, last_index, total_readings
1102        );
1103
1104        Ok(())
1105    }
1106
1107    /// Calculate the start index for incremental sync.
1108    ///
1109    /// Returns the index to start downloading from (1-based).
1110    /// If the device has new readings since last sync, returns the next index.
1111    /// If this is the first sync, returns 1 to download all.
1112    ///
1113    /// # Buffer Wrap-Around Detection
1114    ///
1115    /// Aranet devices have a circular buffer (e.g., ~2016 readings for Aranet4 at 10-min
1116    /// intervals). When the buffer fills up, new readings replace the oldest ones, but
1117    /// `total_readings` stays constant. This function detects this wrap-around case by
1118    /// comparing the latest stored timestamp with the expected time since last sync.
1119    pub fn calculate_sync_start(&self, device_id: &str, current_total: u16) -> Result<u16> {
1120        let state = self.get_sync_state(device_id)?;
1121
1122        match state {
1123            Some(s) if s.total_readings == Some(current_total) => {
1124                // Same total readings as last sync - could mean:
1125                // 1. No new readings (buffer not full, recent sync)
1126                // 2. Buffer wrapped (old readings replaced with new)
1127                // 3. History cache was cleared but sync state exists
1128
1129                // Check if buffer has likely wrapped by comparing timestamps
1130                if s.last_sync_at.is_some() {
1131                    let latest_stored = self.get_latest_history_timestamp(device_id)?;
1132
1133                    match latest_stored {
1134                        Some(latest_ts) => {
1135                            let now = OffsetDateTime::now_utc();
1136                            let time_since_latest = now - latest_ts;
1137
1138                            // If more than 10 minutes since latest record, new data likely exists
1139                            // (10 min is the longest standard Aranet4 interval)
1140                            if time_since_latest > time::Duration::minutes(10) {
1141                                debug!(
1142                                    "Buffer may have wrapped for {} (latest record is {} min old), doing full sync",
1143                                    device_id,
1144                                    time_since_latest.whole_minutes()
1145                                );
1146                                return Ok(1);
1147                            }
1148
1149                            // Recent sync and no indication of wrap-around
1150                            debug!("No new readings for {}", device_id);
1151                            Ok(current_total.saturating_add(1))
1152                        }
1153                        None => {
1154                            // Sync state exists but no history records - cache was likely cleared
1155                            // Do a full sync to repopulate
1156                            debug!(
1157                                "Sync state exists but no history for {}, doing full sync",
1158                                device_id
1159                            );
1160                            Ok(1)
1161                        }
1162                    }
1163                } else {
1164                    // No last_sync_at - shouldn't happen but do full sync to be safe
1165                    debug!("No sync timestamp for {}, doing full sync", device_id);
1166                    Ok(1)
1167                }
1168            }
1169            Some(s) if s.last_history_index.is_some() => {
1170                // We have previous state, calculate new records
1171                let last_index = match s.last_history_index {
1172                    Some(idx) => idx,
1173                    None => unreachable!("guarded by is_some() in match arm"),
1174                };
1175                let prev_total = s.total_readings.unwrap_or(0);
1176
1177                // Check if device was reset (current_total < prev_total)
1178                if current_total < prev_total {
1179                    debug!(
1180                        "Device total decreased ({} -> {}) for {}, device was reset - doing full sync",
1181                        prev_total, current_total, device_id
1182                    );
1183                    return Ok(1);
1184                }
1185
1186                let new_count = current_total.saturating_sub(prev_total);
1187
1188                if new_count > 0 {
1189                    // Start from where we left off
1190                    let start = last_index.saturating_add(1);
1191
1192                    // Validate start index doesn't exceed current total
1193                    // This can happen if device buffer wrapped or was reset
1194                    if start > current_total {
1195                        debug!(
1196                            "Start index {} exceeds device total {} for {}, doing full sync",
1197                            start, current_total, device_id
1198                        );
1199                        return Ok(1);
1200                    }
1201
1202                    debug!(
1203                        "Incremental sync for {}: {} new readings, starting at {}",
1204                        device_id, new_count, start
1205                    );
1206                    Ok(start)
1207                } else {
1208                    Ok(current_total.saturating_add(1))
1209                }
1210            }
1211            _ => {
1212                // First sync - download all
1213                debug!(
1214                    "First sync for {}: downloading all {} readings",
1215                    device_id, current_total
1216                );
1217                Ok(1)
1218            }
1219        }
1220    }
1221
1222    /// Get the timestamp of the most recent history record for a device.
1223    ///
1224    /// Returns `None` if no history exists for the device.
1225    fn get_latest_history_timestamp(&self, device_id: &str) -> Result<Option<OffsetDateTime>> {
1226        let ts: Option<i64> = self
1227            .conn
1228            .query_row(
1229                "SELECT MAX(timestamp) FROM history WHERE device_id = ?",
1230                [device_id],
1231                |row| row.get(0),
1232            )
1233            .optional()?
1234            .flatten();
1235
1236        Ok(ts.map(timestamp_from_unix))
1237    }
1238}
1239
1240/// Aggregate statistics for history data.
1241#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1242pub struct HistoryStats {
1243    /// Number of records.
1244    pub count: u64,
1245    /// Minimum values.
1246    pub min: HistoryAggregates,
1247    /// Maximum values.
1248    pub max: HistoryAggregates,
1249    /// Average values.
1250    pub avg: HistoryAggregates,
1251    /// Time range of records.
1252    pub time_range: Option<(OffsetDateTime, OffsetDateTime)>,
1253}
1254
1255/// Aggregate values for a single metric set.
1256#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1257pub struct HistoryAggregates {
1258    /// CO2 in ppm.
1259    pub co2: Option<f64>,
1260    /// Temperature in Celsius.
1261    pub temperature: Option<f64>,
1262    /// Pressure in hPa.
1263    pub pressure: Option<f64>,
1264    /// Humidity percentage.
1265    pub humidity: Option<f64>,
1266    /// Radon in Bq/m3 (for radon devices).
1267    pub radon: Option<f64>,
1268}
1269
1270// Aggregate and export operations
1271impl Store {
1272    /// Calculate aggregate statistics for history records.
1273    ///
1274    /// Computes min, max, and average values for all sensor metrics across
1275    /// the records matching the query. Useful for dashboards and reports.
1276    ///
1277    /// # Arguments
1278    ///
1279    /// * `query` - Filter which records to include in the statistics
1280    ///
1281    /// # Example
1282    ///
1283    /// ```
1284    /// use aranet_store::{Store, HistoryQuery};
1285    /// use time::{OffsetDateTime, Duration};
1286    ///
1287    /// let store = Store::open_in_memory()?;
1288    ///
1289    /// // Get stats for last 24 hours
1290    /// let yesterday = OffsetDateTime::now_utc() - Duration::hours(24);
1291    /// let query = HistoryQuery::new()
1292    ///     .device("Aranet4 17C3C")
1293    ///     .since(yesterday);
1294    ///
1295    /// let stats = store.history_stats(&query)?;
1296    /// if let Some(avg_co2) = stats.avg.co2 {
1297    ///     println!("Average CO2: {:.0} ppm", avg_co2);
1298    /// }
1299    /// if let Some((start, end)) = stats.time_range {
1300    ///     println!("Time range: {} to {}", start, end);
1301    /// }
1302    /// # Ok::<(), aranet_store::Error>(())
1303    /// ```
1304    pub fn history_stats(&self, query: &HistoryQuery) -> Result<HistoryStats> {
1305        let mut conditions = Vec::new();
1306        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1307
1308        if let Some(ref device_id) = query.device_id {
1309            conditions.push("device_id = ?");
1310            params.push(Box::new(device_id.clone()));
1311        }
1312
1313        if let Some(since) = query.since {
1314            conditions.push("timestamp >= ?");
1315            params.push(Box::new(since.unix_timestamp()));
1316        }
1317
1318        if let Some(until) = query.until {
1319            conditions.push("timestamp <= ?");
1320            params.push(Box::new(until.unix_timestamp()));
1321        }
1322
1323        let where_clause = if conditions.is_empty() {
1324            String::new()
1325        } else {
1326            format!("WHERE {}", conditions.join(" AND "))
1327        };
1328
1329        let sql = format!(
1330            "SELECT
1331                COUNT(*) as count,
1332                MIN(co2) as min_co2, MAX(co2) as max_co2, AVG(co2) as avg_co2,
1333                MIN(temperature) as min_temp, MAX(temperature) as max_temp, AVG(temperature) as avg_temp,
1334                MIN(pressure) as min_press, MAX(pressure) as max_press, AVG(pressure) as avg_press,
1335                MIN(humidity) as min_hum, MAX(humidity) as max_hum, AVG(humidity) as avg_hum,
1336                MIN(radon) as min_radon, MAX(radon) as max_radon, AVG(radon) as avg_radon,
1337                MIN(timestamp) as min_ts, MAX(timestamp) as max_ts
1338             FROM history {}",
1339            where_clause
1340        );
1341
1342        let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1343
1344        let stats = self.conn.query_row(&sql, params_refs.as_slice(), |row| {
1345            let count: i64 = row.get(0)?;
1346            let min_ts: Option<i64> = row.get(16)?;
1347            let max_ts: Option<i64> = row.get(17)?;
1348
1349            let time_range = match (min_ts, max_ts) {
1350                (Some(min), Some(max)) => {
1351                    Some((timestamp_from_unix(min), timestamp_from_unix(max)))
1352                }
1353                _ => None,
1354            };
1355
1356            Ok(HistoryStats {
1357                count: count as u64,
1358                min: HistoryAggregates {
1359                    co2: row.get::<_, Option<i64>>(1)?.map(|v| v as f64),
1360                    temperature: row.get(4)?,
1361                    pressure: row.get(7)?,
1362                    humidity: row.get::<_, Option<i64>>(10)?.map(|v| v as f64),
1363                    radon: row.get::<_, Option<i64>>(13)?.map(|v| v as f64),
1364                },
1365                max: HistoryAggregates {
1366                    co2: row.get::<_, Option<i64>>(2)?.map(|v| v as f64),
1367                    temperature: row.get(5)?,
1368                    pressure: row.get(8)?,
1369                    humidity: row.get::<_, Option<i64>>(11)?.map(|v| v as f64),
1370                    radon: row.get::<_, Option<i64>>(14)?.map(|v| v as f64),
1371                },
1372                avg: HistoryAggregates {
1373                    co2: row.get(3)?,
1374                    temperature: row.get(6)?,
1375                    pressure: row.get(9)?,
1376                    humidity: row.get(12)?,
1377                    radon: row.get(15)?,
1378                },
1379                time_range,
1380            })
1381        })?;
1382
1383        Ok(stats)
1384    }
1385
1386    /// Export history records to CSV format.
1387    ///
1388    /// Exports records matching the query to a CSV string with the following columns:
1389    /// `timestamp`, `device_id`, `co2`, `temperature`, `pressure`, `humidity`, `radon`.
1390    ///
1391    /// Timestamps are formatted as RFC 3339 (e.g., `2024-01-15T10:30:00Z`).
1392    ///
1393    /// # Arguments
1394    ///
1395    /// * `query` - Filter which records to export
1396    ///
1397    /// # Example
1398    ///
1399    /// ```
1400    /// use aranet_store::{Store, HistoryQuery};
1401    /// use std::fs;
1402    ///
1403    /// let store = Store::open_in_memory()?;
1404    ///
1405    /// let query = HistoryQuery::new().device("Aranet4 17C3C").oldest_first();
1406    /// let csv = store.export_history_csv(&query)?;
1407    ///
1408    /// // Write to file
1409    /// // fs::write("history.csv", &csv)?;
1410    /// # Ok::<(), aranet_store::Error>(())
1411    /// ```
1412    pub fn export_history_csv(&self, query: &HistoryQuery) -> Result<String> {
1413        let sql = query.build_sql_with_select(
1414            "SELECT timestamp, device_id, co2, temperature, pressure, humidity, radon, \
1415             radiation_rate, radiation_total FROM history",
1416        );
1417        let (_, params) = query.build_where();
1418        let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1419        let mut stmt = self.conn.prepare(&sql)?;
1420        let mut wtr = csv::Writer::from_writer(Vec::new());
1421
1422        // Header
1423        wtr.write_record([
1424            "timestamp",
1425            "device_id",
1426            "co2",
1427            "temperature",
1428            "pressure",
1429            "humidity",
1430            "radon",
1431            "radiation_rate",
1432            "radiation_total",
1433        ])
1434        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
1435
1436        let rows = stmt.query_map(params_ref.as_slice(), |row| {
1437            Ok((
1438                timestamp_from_unix(row.get(0)?),
1439                row.get::<_, String>(1)?,
1440                u16::try_from(row.get::<_, i64>(2)?).unwrap_or_else(|e| {
1441                    warn!("Invalid co2 value in export row: {e}");
1442                    0
1443                }),
1444                row.get::<_, f32>(3)?,
1445                row.get::<_, f32>(4)?,
1446                u8::try_from(row.get::<_, i64>(5)?).unwrap_or_else(|e| {
1447                    warn!("Invalid humidity value in export row: {e}");
1448                    0
1449                }),
1450                row.get::<_, Option<i64>>(6)?
1451                    .and_then(|v| u32::try_from(v).ok()),
1452                row.get::<_, Option<f64>>(7)?,
1453                row.get::<_, Option<f64>>(8)?,
1454            ))
1455        })?;
1456
1457        // Data rows
1458        for row in rows {
1459            let (
1460                timestamp,
1461                device_id,
1462                co2,
1463                temperature,
1464                pressure,
1465                humidity,
1466                radon,
1467                radiation_rate,
1468                radiation_total,
1469            ) = row?;
1470            let timestamp = match timestamp.format(&time::format_description::well_known::Rfc3339) {
1471                Ok(ts) => ts,
1472                Err(e) => {
1473                    warn!("Skipping CSV row with unformattable timestamp: {e}");
1474                    continue;
1475                }
1476            };
1477            let radon = radon.map(|r| r.to_string()).unwrap_or_default();
1478            let radiation_rate = radiation_rate
1479                .map(|r| format!("{:.4}", r))
1480                .unwrap_or_default();
1481            let radiation_total = radiation_total
1482                .map(|r| format!("{:.4}", r))
1483                .unwrap_or_default();
1484
1485            wtr.write_record(&[
1486                timestamp,
1487                device_id,
1488                co2.to_string(),
1489                format!("{:.1}", temperature),
1490                format!("{:.2}", pressure),
1491                humidity.to_string(),
1492                radon,
1493                radiation_rate,
1494                radiation_total,
1495            ])
1496            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
1497        }
1498
1499        let bytes = wtr
1500            .into_inner()
1501            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
1502        String::from_utf8(bytes).map_err(|e| Error::Io(std::io::Error::other(e)))
1503    }
1504
1505    /// Export history records to JSON format.
1506    ///
1507    /// Exports records matching the query as a pretty-printed JSON array of
1508    /// [`StoredHistoryRecord`] objects.
1509    ///
1510    /// # Arguments
1511    ///
1512    /// * `query` - Filter which records to export
1513    ///
1514    /// # Example
1515    ///
1516    /// ```
1517    /// use aranet_store::{Store, HistoryQuery};
1518    ///
1519    /// let store = Store::open_in_memory()?;
1520    ///
1521    /// let query = HistoryQuery::new().device("Aranet4 17C3C");
1522    /// let json = store.export_history_json(&query)?;
1523    /// println!("{}", json);
1524    /// # Ok::<(), aranet_store::Error>(())
1525    /// ```
1526    pub fn export_history_json(&self, query: &HistoryQuery) -> Result<String> {
1527        let sql = query.build_sql();
1528        let (_, params) = query.build_where();
1529        let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1530        let mut stmt = self.conn.prepare(&sql)?;
1531        let rows = stmt.query_map(params_ref.as_slice(), |row| {
1532            Ok(StoredHistoryRecord {
1533                id: row.get(0)?,
1534                device_id: row.get(1)?,
1535                timestamp: timestamp_from_unix(row.get(2)?),
1536                synced_at: timestamp_from_unix(row.get(3)?),
1537                co2: u16::try_from(row.get::<_, i64>(4)?).unwrap_or_else(|e| {
1538                    warn!("Invalid co2 value in JSON export: {e}");
1539                    0
1540                }),
1541                temperature: row.get(5)?,
1542                pressure: row.get(6)?,
1543                humidity: u8::try_from(row.get::<_, i64>(7)?).unwrap_or_else(|e| {
1544                    warn!("Invalid humidity value in JSON export: {e}");
1545                    0
1546                }),
1547                radon: row
1548                    .get::<_, Option<i64>>(8)?
1549                    .and_then(|v| radon_from_i64(v, "json_export")),
1550                radiation_rate: row.get(9)?,
1551                radiation_total: row.get(10)?,
1552            })
1553        })?;
1554
1555        let mut json = String::from("[");
1556        let mut first = true;
1557
1558        for row in rows {
1559            let record = row?;
1560            let record_json = serde_json::to_string_pretty(&record)?;
1561            if first {
1562                json.push('\n');
1563                first = false;
1564            } else {
1565                json.push_str(",\n");
1566            }
1567
1568            for line in record_json.lines() {
1569                json.push_str("  ");
1570                json.push_str(line);
1571                json.push('\n');
1572            }
1573        }
1574
1575        json.push(']');
1576
1577        Ok(json)
1578    }
1579
1580    /// Import history records from CSV format.
1581    ///
1582    /// Expected CSV format:
1583    /// ```csv
1584    /// timestamp,device_id,co2,temperature,pressure,humidity,radon
1585    /// 2024-01-15T10:30:00Z,Aranet4 17C3C,800,22.5,1013.25,45,
1586    /// ```
1587    ///
1588    /// Returns the number of records imported (deduplicated by device_id + timestamp).
1589    pub fn import_history_csv(&self, csv_data: &str) -> Result<ImportResult> {
1590        let mut reader = csv::ReaderBuilder::new()
1591            .has_headers(true)
1592            .flexible(true)
1593            .trim(csv::Trim::All)
1594            .from_reader(csv_data.as_bytes());
1595
1596        let mut total = 0;
1597        let mut imported = 0;
1598        let mut skipped = 0;
1599        let mut errors = Vec::new();
1600        let mut device_records: std::collections::HashMap<String, Vec<HistoryRecord>> =
1601            std::collections::HashMap::new();
1602
1603        for (line_num, result) in reader.records().enumerate() {
1604            total += 1;
1605            let line = line_num + 2; // Account for header and 0-indexing
1606
1607            let record = match result {
1608                Ok(r) => r,
1609                Err(e) => {
1610                    errors.push(format!("Line {}: parse error - {}", line, e));
1611                    skipped += 1;
1612                    continue;
1613                }
1614            };
1615
1616            // Parse fields
1617            let timestamp_str = record.get(0).unwrap_or("").trim();
1618            let device_id = record.get(1).unwrap_or("").trim();
1619            let co2_str = record.get(2).unwrap_or("").trim();
1620            let temp_str = record.get(3).unwrap_or("").trim();
1621            let pressure_str = record.get(4).unwrap_or("").trim();
1622            let humidity_str = record.get(5).unwrap_or("").trim();
1623            let radon_str = record.get(6).unwrap_or("").trim();
1624
1625            // Validate required fields
1626            if device_id.is_empty() {
1627                errors.push(format!("Line {}: missing device_id", line));
1628                skipped += 1;
1629                continue;
1630            }
1631
1632            if timestamp_str.is_empty() {
1633                errors.push(format!("Line {}: missing timestamp", line));
1634                skipped += 1;
1635                continue;
1636            }
1637
1638            // Parse timestamp
1639            let timestamp = match OffsetDateTime::parse(
1640                timestamp_str,
1641                &time::format_description::well_known::Rfc3339,
1642            ) {
1643                Ok(ts) => ts,
1644                Err(_) => {
1645                    errors.push(format!(
1646                        "Line {}: invalid timestamp '{}'",
1647                        line, timestamp_str
1648                    ));
1649                    skipped += 1;
1650                    continue;
1651                }
1652            };
1653
1654            // Parse numeric fields with defaults and validation
1655            // Parse as u32 first so values > u16::MAX (65535) produce a clear
1656            // "exceeds maximum" message instead of a generic parse error.
1657            let co2: u16 = match co2_str.parse::<u32>() {
1658                Ok(v) if v <= 10000 => v as u16, // CO2 sensor max is typically 10000 ppm
1659                Ok(v) => {
1660                    errors.push(format!(
1661                        "Line {}: CO2 value {} exceeds maximum of 10000 ppm",
1662                        line, v
1663                    ));
1664                    skipped += 1;
1665                    continue;
1666                }
1667                Err(_) if co2_str.is_empty() => 0,
1668                Err(_) => {
1669                    errors.push(format!("Line {}: invalid CO2 value '{}'", line, co2_str));
1670                    skipped += 1;
1671                    continue;
1672                }
1673            };
1674
1675            let temperature: f32 = match temp_str.parse::<f32>() {
1676                Ok(v) if (-40.0..=100.0).contains(&v) => v,
1677                Ok(v) => {
1678                    errors.push(format!(
1679                        "Line {}: temperature {} is outside valid range (-40 to 100°C)",
1680                        line, v
1681                    ));
1682                    skipped += 1;
1683                    continue;
1684                }
1685                Err(_) if temp_str.is_empty() => 0.0,
1686                Err(_) => {
1687                    errors.push(format!(
1688                        "Line {}: invalid temperature value '{}'",
1689                        line, temp_str
1690                    ));
1691                    skipped += 1;
1692                    continue;
1693                }
1694            };
1695
1696            let pressure: f32 = match pressure_str.parse::<f32>() {
1697                Ok(v) if v == 0.0 || (800.0..=1200.0).contains(&v) => v,
1698                Ok(v) => {
1699                    errors.push(format!(
1700                        "Line {}: pressure {} is outside valid range (800-1200 hPa)",
1701                        line, v
1702                    ));
1703                    skipped += 1;
1704                    continue;
1705                }
1706                Err(_) if pressure_str.is_empty() => 0.0,
1707                Err(_) => {
1708                    errors.push(format!(
1709                        "Line {}: invalid pressure value '{}'",
1710                        line, pressure_str
1711                    ));
1712                    skipped += 1;
1713                    continue;
1714                }
1715            };
1716
1717            let humidity: u8 = match humidity_str.parse::<u8>() {
1718                Ok(v) if v <= 100 => v,
1719                Ok(v) => {
1720                    errors.push(format!(
1721                        "Line {}: humidity {} exceeds maximum of 100%",
1722                        line, v
1723                    ));
1724                    skipped += 1;
1725                    continue;
1726                }
1727                Err(_) if humidity_str.is_empty() => 0,
1728                Err(_) => {
1729                    errors.push(format!(
1730                        "Line {}: invalid humidity value '{}'",
1731                        line, humidity_str
1732                    ));
1733                    skipped += 1;
1734                    continue;
1735                }
1736            };
1737
1738            let radon: Option<u32> = if radon_str.is_empty() {
1739                None
1740            } else {
1741                match radon_str.parse::<u32>() {
1742                    Ok(v) if v <= 100000 => Some(v), // Radon max ~100000 Bq/m³
1743                    Ok(v) => {
1744                        errors.push(format!(
1745                            "Line {}: radon value {} exceeds maximum of 100000 Bq/m³",
1746                            line, v
1747                        ));
1748                        skipped += 1;
1749                        continue;
1750                    }
1751                    Err(_) => {
1752                        errors.push(format!(
1753                            "Line {}: invalid radon value '{}'",
1754                            line, radon_str
1755                        ));
1756                        skipped += 1;
1757                        continue;
1758                    }
1759                }
1760            };
1761
1762            // Create history record
1763            let history_record = HistoryRecord {
1764                timestamp,
1765                co2,
1766                temperature,
1767                pressure,
1768                humidity,
1769                radon,
1770                radiation_rate: None,
1771                radiation_total: None,
1772            };
1773
1774            device_records
1775                .entry(device_id.to_string())
1776                .or_default()
1777                .push(history_record);
1778        }
1779
1780        for (device_id, records) in device_records {
1781            self.upsert_device(&device_id, None)?;
1782            let total_records = records.len();
1783            let count = self.insert_history(&device_id, &records)?;
1784            imported += count;
1785            skipped += total_records.saturating_sub(count);
1786        }
1787
1788        Ok(ImportResult {
1789            total,
1790            imported,
1791            skipped,
1792            errors,
1793        })
1794    }
1795
1796    /// Import history records from JSON format.
1797    ///
1798    /// Expected JSON format: an array of StoredHistoryRecord objects.
1799    ///
1800    /// Returns the number of records imported (deduplicated by device_id + timestamp).
1801    pub fn import_history_json(&self, json_data: &str) -> Result<ImportResult> {
1802        let records: Vec<StoredHistoryRecord> = serde_json::from_str(json_data)?;
1803
1804        let total = records.len();
1805        let mut imported = 0;
1806        let mut skipped = 0;
1807        let mut device_records: std::collections::HashMap<String, Vec<HistoryRecord>> =
1808            std::collections::HashMap::new();
1809
1810        for record in records {
1811            let device_id = record.device_id.clone();
1812            device_records
1813                .entry(device_id)
1814                .or_default()
1815                .push(record.to_history());
1816        }
1817
1818        for (device_id, records) in device_records {
1819            self.upsert_device(&device_id, None)?;
1820            let total_records = records.len();
1821            let count = self.insert_history(&device_id, &records)?;
1822            imported += count;
1823            skipped += total_records.saturating_sub(count);
1824        }
1825
1826        Ok(ImportResult {
1827            total,
1828            imported,
1829            skipped,
1830            errors: Vec::new(),
1831        })
1832    }
1833}
1834
1835/// Result of an import operation.
1836#[derive(Debug, Clone)]
1837pub struct ImportResult {
1838    /// Total records processed.
1839    pub total: usize,
1840    /// Records successfully imported.
1841    pub imported: usize,
1842    /// Records skipped (duplicates or errors).
1843    pub skipped: usize,
1844    /// Error messages for failed records.
1845    pub errors: Vec<String>,
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850    use super::*;
1851    use aranet_types::Status;
1852
1853    fn create_test_reading() -> CurrentReading {
1854        CurrentReading {
1855            co2: 800,
1856            temperature: 22.5,
1857            pressure: 1013.0,
1858            humidity: 45,
1859            battery: 85,
1860            status: Status::Green,
1861            interval: 60,
1862            age: 30,
1863            captured_at: Some(OffsetDateTime::now_utc()),
1864            radon: None,
1865            radiation_rate: None,
1866            radiation_total: None,
1867            radon_avg_24h: None,
1868            radon_avg_7d: None,
1869            radon_avg_30d: None,
1870        }
1871    }
1872
1873    #[test]
1874    fn test_open_in_memory() {
1875        let store = Store::open_in_memory().unwrap();
1876        let devices = store.list_devices().unwrap();
1877        assert!(devices.is_empty());
1878    }
1879
1880    #[test]
1881    fn test_upsert_device() {
1882        let store = Store::open_in_memory().unwrap();
1883
1884        let device = store.upsert_device("test-device", Some("Test")).unwrap();
1885        assert_eq!(device.id, "test-device");
1886        assert_eq!(device.name, Some("Test".to_string()));
1887
1888        // Update name
1889        let device = store
1890            .upsert_device("test-device", Some("New Name"))
1891            .unwrap();
1892        assert_eq!(device.name, Some("New Name".to_string()));
1893    }
1894
1895    #[test]
1896    fn test_insert_and_query_reading() {
1897        let store = Store::open_in_memory().unwrap();
1898        let reading = create_test_reading();
1899
1900        store.insert_reading("test-device", &reading).unwrap();
1901
1902        let query = ReadingQuery::new().device("test-device");
1903        let readings = store.query_readings(&query).unwrap();
1904
1905        assert_eq!(readings.len(), 1);
1906        assert_eq!(readings[0].co2, 800);
1907        assert_eq!(readings[0].temperature, 22.5);
1908    }
1909
1910    #[test]
1911    fn test_get_latest_reading() {
1912        let store = Store::open_in_memory().unwrap();
1913
1914        let mut reading1 = create_test_reading();
1915        reading1.co2 = 700;
1916        store.insert_reading("test-device", &reading1).unwrap();
1917
1918        let mut reading2 = create_test_reading();
1919        reading2.co2 = 900;
1920        store.insert_reading("test-device", &reading2).unwrap();
1921
1922        let latest = store.get_latest_reading("test-device").unwrap().unwrap();
1923        assert_eq!(latest.co2, 900);
1924    }
1925
1926    #[test]
1927    fn test_list_latest_readings_returns_one_row_per_device() {
1928        let store = Store::open_in_memory().unwrap();
1929
1930        let mut alpha_old = create_test_reading();
1931        alpha_old.co2 = 700;
1932        alpha_old.captured_at = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1));
1933        store.insert_reading("alpha", &alpha_old).unwrap();
1934
1935        let mut alpha_new = create_test_reading();
1936        alpha_new.co2 = 900;
1937        alpha_new.captured_at = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(2));
1938        store.insert_reading("alpha", &alpha_new).unwrap();
1939
1940        let mut beta = create_test_reading();
1941        beta.co2 = 500;
1942        beta.captured_at = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(3));
1943        store.insert_reading("beta", &beta).unwrap();
1944
1945        store.upsert_device("gamma", Some("No Reading")).unwrap();
1946
1947        let latest = store.list_latest_readings().unwrap();
1948
1949        assert_eq!(latest.len(), 2);
1950        let latest_by_device: std::collections::HashMap<_, _> = latest
1951            .into_iter()
1952            .map(|(device, reading)| (device.id, reading.co2))
1953            .collect();
1954        assert_eq!(latest_by_device.get("alpha"), Some(&900));
1955        assert_eq!(latest_by_device.get("beta"), Some(&500));
1956        assert!(!latest_by_device.contains_key("gamma"));
1957    }
1958
1959    #[test]
1960    fn test_insert_history_deduplication() {
1961        let store = Store::open_in_memory().unwrap();
1962
1963        let now = OffsetDateTime::now_utc();
1964        let records = vec![
1965            HistoryRecord {
1966                timestamp: now,
1967                co2: 800,
1968                temperature: 22.0,
1969                pressure: 1013.0,
1970                humidity: 45,
1971                radon: None,
1972                radiation_rate: None,
1973                radiation_total: None,
1974            },
1975            HistoryRecord {
1976                timestamp: now, // Same timestamp - should be deduplicated
1977                co2: 850,
1978                temperature: 23.0,
1979                pressure: 1014.0,
1980                humidity: 46,
1981                radon: None,
1982                radiation_rate: None,
1983                radiation_total: None,
1984            },
1985        ];
1986
1987        let inserted = store.insert_history("test-device", &records).unwrap();
1988        assert_eq!(inserted, 1); // Only one inserted due to dedup
1989
1990        let count = store.count_history(Some("test-device")).unwrap();
1991        assert_eq!(count, 1);
1992    }
1993
1994    #[test]
1995    fn test_sync_state() {
1996        let store = Store::open_in_memory().unwrap();
1997        store.upsert_device("test-device", None).unwrap();
1998
1999        // Initially no sync state
2000        let state = store.get_sync_state("test-device").unwrap();
2001        assert!(state.is_none());
2002
2003        // Update sync state
2004        store.update_sync_state("test-device", 100, 100).unwrap();
2005
2006        let state = store.get_sync_state("test-device").unwrap().unwrap();
2007        assert_eq!(state.last_history_index, Some(100));
2008        assert_eq!(state.total_readings, Some(100));
2009        assert!(state.last_sync_at.is_some());
2010    }
2011
2012    #[test]
2013    fn test_calculate_sync_start() {
2014        let store = Store::open_in_memory().unwrap();
2015        store.upsert_device("test-device", None).unwrap();
2016
2017        // First sync - should start from 1
2018        let start = store.calculate_sync_start("test-device", 100).unwrap();
2019        assert_eq!(start, 1);
2020
2021        // Simulate syncing: insert history records and update state
2022        let now = OffsetDateTime::now_utc();
2023        let records = vec![HistoryRecord {
2024            timestamp: now,
2025            co2: 800,
2026            temperature: 22.0,
2027            pressure: 1013.0,
2028            humidity: 45,
2029            radon: None,
2030            radiation_rate: None,
2031            radiation_total: None,
2032        }];
2033        store.insert_history("test-device", &records).unwrap();
2034        store.update_sync_state("test-device", 100, 100).unwrap();
2035
2036        // No new readings and recent history exists - should return beyond range
2037        let start = store.calculate_sync_start("test-device", 100).unwrap();
2038        assert_eq!(start, 101);
2039
2040        // New readings added - should start from 101
2041        let start = store.calculate_sync_start("test-device", 110).unwrap();
2042        assert_eq!(start, 101);
2043    }
2044
2045    #[test]
2046    fn test_calculate_sync_start_cache_cleared() {
2047        let store = Store::open_in_memory().unwrap();
2048        store.upsert_device("test-device", None).unwrap();
2049
2050        // Simulate previous sync
2051        store.update_sync_state("test-device", 100, 100).unwrap();
2052
2053        // No history records exist (cache was cleared) - should do full sync
2054        let start = store.calculate_sync_start("test-device", 100).unwrap();
2055        assert_eq!(start, 1);
2056    }
2057
2058    #[test]
2059    fn test_calculate_sync_start_buffer_wrapped() {
2060        let store = Store::open_in_memory().unwrap();
2061        store.upsert_device("test-device", None).unwrap();
2062
2063        // Insert an old history record (more than 10 min ago)
2064        let old_time = OffsetDateTime::now_utc() - time::Duration::minutes(30);
2065        let records = vec![HistoryRecord {
2066            timestamp: old_time,
2067            co2: 800,
2068            temperature: 22.0,
2069            pressure: 1013.0,
2070            humidity: 45,
2071            radon: None,
2072            radiation_rate: None,
2073            radiation_total: None,
2074        }];
2075        store.insert_history("test-device", &records).unwrap();
2076        store.update_sync_state("test-device", 100, 100).unwrap();
2077
2078        // Device still shows 100 readings but latest record is old
2079        // This indicates buffer may have wrapped - should do full sync
2080        let start = store.calculate_sync_start("test-device", 100).unwrap();
2081        assert_eq!(start, 1);
2082    }
2083
2084    #[test]
2085    fn test_calculate_sync_start_index_overflow() {
2086        let store = Store::open_in_memory().unwrap();
2087        store.upsert_device("test-device", None).unwrap();
2088
2089        // Simulate state where last_index exceeds current_total (buffer reset)
2090        store.update_sync_state("test-device", 500, 500).unwrap();
2091
2092        // Device was reset and now has fewer readings
2093        // start would be 501 which exceeds 200, should do full sync
2094        let start = store.calculate_sync_start("test-device", 200).unwrap();
2095        assert_eq!(start, 1);
2096    }
2097
2098    #[test]
2099    fn test_import_history_csv() {
2100        let store = Store::open_in_memory().unwrap();
2101
2102        let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
21032024-01-15T10:30:00Z,Aranet4 17C3C,800,22.5,1013.25,45,
21042024-01-15T11:30:00Z,Aranet4 17C3C,850,23.0,1014.00,48,
21052024-01-15T12:30:00Z,AranetRn+ 306B8,0,21.0,1012.00,50,150
2106"#;
2107
2108        let result = store.import_history_csv(csv_data).unwrap();
2109
2110        assert_eq!(result.total, 3);
2111        assert_eq!(result.imported, 3);
2112        assert_eq!(result.skipped, 0);
2113        assert!(result.errors.is_empty());
2114
2115        // Verify data was imported
2116        let devices = store.list_devices().unwrap();
2117        assert_eq!(devices.len(), 2);
2118
2119        // Query defaults to newest_first=true (DESC order)
2120        let query = HistoryQuery::new().device("Aranet4 17C3C");
2121        let records = store.query_history(&query).unwrap();
2122        assert_eq!(records.len(), 2);
2123        assert_eq!(records[0].co2, 850); // 11:30 - newest first
2124        assert_eq!(records[1].co2, 800); // 10:30 - oldest
2125
2126        // Verify radon device
2127        let query = HistoryQuery::new().device("AranetRn+ 306B8");
2128        let records = store.query_history(&query).unwrap();
2129        assert_eq!(records.len(), 1);
2130        assert_eq!(records[0].radon, Some(150));
2131    }
2132
2133    #[test]
2134    fn test_import_history_csv_deduplication() {
2135        let store = Store::open_in_memory().unwrap();
2136
2137        let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
21382024-01-15T10:30:00Z,test-device,800,22.5,1013.25,45,
2139"#;
2140
2141        // Import once
2142        let result = store.import_history_csv(csv_data).unwrap();
2143        assert_eq!(result.imported, 1);
2144
2145        // Import again - should skip duplicate
2146        let result = store.import_history_csv(csv_data).unwrap();
2147        assert_eq!(result.imported, 0);
2148        assert_eq!(result.skipped, 1);
2149    }
2150
2151    #[test]
2152    fn test_import_history_csv_with_errors() {
2153        let store = Store::open_in_memory().unwrap();
2154
2155        let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
2156invalid-timestamp,test-device,800,22.5,1013.25,45,
21572024-01-15T10:30:00Z,,800,22.5,1013.25,45,
21582024-01-15T11:30:00Z,valid-device,900,23.0,1014.00,50,
2159"#;
2160
2161        let result = store.import_history_csv(csv_data).unwrap();
2162
2163        assert_eq!(result.total, 3);
2164        assert_eq!(result.imported, 1);
2165        assert_eq!(result.skipped, 2);
2166        assert_eq!(result.errors.len(), 2);
2167    }
2168
2169    #[test]
2170    fn test_import_history_json() {
2171        let store = Store::open_in_memory().unwrap();
2172
2173        let json_data = r#"[
2174            {
2175                "id": 0,
2176                "device_id": "Aranet4 17C3C",
2177                "timestamp": "2024-01-15T10:30:00Z",
2178                "synced_at": "2024-01-15T12:00:00Z",
2179                "co2": 800,
2180                "temperature": 22.5,
2181                "pressure": 1013.25,
2182                "humidity": 45,
2183                "radon": null,
2184                "radiation_rate": null,
2185                "radiation_total": null
2186            },
2187            {
2188                "id": 0,
2189                "device_id": "Aranet4 17C3C",
2190                "timestamp": "2024-01-15T11:30:00Z",
2191                "synced_at": "2024-01-15T12:00:00Z",
2192                "co2": 850,
2193                "temperature": 23.0,
2194                "pressure": 1014.0,
2195                "humidity": 48,
2196                "radon": null,
2197                "radiation_rate": null,
2198                "radiation_total": null
2199            }
2200        ]"#;
2201
2202        let result = store.import_history_json(json_data).unwrap();
2203
2204        assert_eq!(result.total, 2);
2205        assert_eq!(result.imported, 2);
2206        assert_eq!(result.skipped, 0);
2207
2208        // Verify data was imported
2209        let query = HistoryQuery::new().device("Aranet4 17C3C");
2210        let records = store.query_history(&query).unwrap();
2211        assert_eq!(records.len(), 2);
2212    }
2213
2214    // ==================== History Stats Tests ====================
2215
2216    #[test]
2217    fn test_history_stats_empty() {
2218        let store = Store::open_in_memory().unwrap();
2219
2220        let query = HistoryQuery::new();
2221        let stats = store.history_stats(&query).unwrap();
2222
2223        assert_eq!(stats.count, 0);
2224        assert!(stats.min.co2.is_none());
2225        assert!(stats.max.co2.is_none());
2226        assert!(stats.avg.co2.is_none());
2227        assert!(stats.time_range.is_none());
2228    }
2229
2230    #[test]
2231    fn test_history_stats_single_record() {
2232        let store = Store::open_in_memory().unwrap();
2233
2234        let now = OffsetDateTime::now_utc();
2235        let records = vec![HistoryRecord {
2236            timestamp: now,
2237            co2: 800,
2238            temperature: 22.5,
2239            pressure: 1013.0,
2240            humidity: 45,
2241            radon: None,
2242            radiation_rate: None,
2243            radiation_total: None,
2244        }];
2245
2246        store.insert_history("test-device", &records).unwrap();
2247
2248        let query = HistoryQuery::new();
2249        let stats = store.history_stats(&query).unwrap();
2250
2251        assert_eq!(stats.count, 1);
2252        assert_eq!(stats.min.co2, Some(800.0));
2253        assert_eq!(stats.max.co2, Some(800.0));
2254        assert_eq!(stats.avg.co2, Some(800.0));
2255        assert_eq!(stats.min.temperature, Some(22.5));
2256        assert_eq!(stats.max.temperature, Some(22.5));
2257    }
2258
2259    #[test]
2260    fn test_history_stats_multiple_records() {
2261        let store = Store::open_in_memory().unwrap();
2262
2263        let base_time = OffsetDateTime::now_utc();
2264        let records = vec![
2265            HistoryRecord {
2266                timestamp: base_time,
2267                co2: 600,
2268                temperature: 20.0,
2269                pressure: 1010.0,
2270                humidity: 40,
2271                radon: None,
2272                radiation_rate: None,
2273                radiation_total: None,
2274            },
2275            HistoryRecord {
2276                timestamp: base_time + time::Duration::hours(1),
2277                co2: 800,
2278                temperature: 22.0,
2279                pressure: 1012.0,
2280                humidity: 50,
2281                radon: None,
2282                radiation_rate: None,
2283                radiation_total: None,
2284            },
2285            HistoryRecord {
2286                timestamp: base_time + time::Duration::hours(2),
2287                co2: 1000,
2288                temperature: 24.0,
2289                pressure: 1014.0,
2290                humidity: 60,
2291                radon: None,
2292                radiation_rate: None,
2293                radiation_total: None,
2294            },
2295        ];
2296
2297        store.insert_history("test-device", &records).unwrap();
2298
2299        let query = HistoryQuery::new();
2300        let stats = store.history_stats(&query).unwrap();
2301
2302        assert_eq!(stats.count, 3);
2303        assert_eq!(stats.min.co2, Some(600.0));
2304        assert_eq!(stats.max.co2, Some(1000.0));
2305        assert_eq!(stats.avg.co2, Some(800.0));
2306        assert_eq!(stats.min.temperature, Some(20.0));
2307        assert_eq!(stats.max.temperature, Some(24.0));
2308        assert_eq!(stats.avg.humidity, Some(50.0));
2309    }
2310
2311    #[test]
2312    fn test_history_stats_with_device_filter() {
2313        let store = Store::open_in_memory().unwrap();
2314
2315        let now = OffsetDateTime::now_utc();
2316
2317        // Device 1 - high CO2
2318        store
2319            .insert_history(
2320                "device-1",
2321                &[HistoryRecord {
2322                    timestamp: now,
2323                    co2: 1200,
2324                    temperature: 25.0,
2325                    pressure: 1015.0,
2326                    humidity: 55,
2327                    radon: None,
2328                    radiation_rate: None,
2329                    radiation_total: None,
2330                }],
2331            )
2332            .unwrap();
2333
2334        // Device 2 - low CO2
2335        store
2336            .insert_history(
2337                "device-2",
2338                &[HistoryRecord {
2339                    timestamp: now,
2340                    co2: 400,
2341                    temperature: 18.0,
2342                    pressure: 1010.0,
2343                    humidity: 35,
2344                    radon: None,
2345                    radiation_rate: None,
2346                    radiation_total: None,
2347                }],
2348            )
2349            .unwrap();
2350
2351        // Stats for device 1 only
2352        let query = HistoryQuery::new().device("device-1");
2353        let stats = store.history_stats(&query).unwrap();
2354
2355        assert_eq!(stats.count, 1);
2356        assert_eq!(stats.avg.co2, Some(1200.0));
2357    }
2358
2359    #[test]
2360    fn test_history_stats_with_time_range() {
2361        let store = Store::open_in_memory().unwrap();
2362
2363        let base_time = OffsetDateTime::now_utc();
2364        let records = vec![
2365            HistoryRecord {
2366                timestamp: base_time - time::Duration::days(2),
2367                co2: 500,
2368                temperature: 19.0,
2369                pressure: 1008.0,
2370                humidity: 40,
2371                radon: None,
2372                radiation_rate: None,
2373                radiation_total: None,
2374            },
2375            HistoryRecord {
2376                timestamp: base_time,
2377                co2: 800,
2378                temperature: 22.0,
2379                pressure: 1012.0,
2380                humidity: 50,
2381                radon: None,
2382                radiation_rate: None,
2383                radiation_total: None,
2384            },
2385        ];
2386
2387        store.insert_history("test-device", &records).unwrap();
2388
2389        // Query only recent records
2390        let query = HistoryQuery::new().since(base_time - time::Duration::hours(1));
2391        let stats = store.history_stats(&query).unwrap();
2392
2393        assert_eq!(stats.count, 1);
2394        assert_eq!(stats.avg.co2, Some(800.0));
2395    }
2396
2397    #[test]
2398    fn test_history_stats_with_radon() {
2399        let store = Store::open_in_memory().unwrap();
2400
2401        let now = OffsetDateTime::now_utc();
2402        let records = vec![
2403            HistoryRecord {
2404                timestamp: now,
2405                co2: 0,
2406                temperature: 20.0,
2407                pressure: 1010.0,
2408                humidity: 50,
2409                radon: Some(100),
2410                radiation_rate: None,
2411                radiation_total: None,
2412            },
2413            HistoryRecord {
2414                timestamp: now + time::Duration::hours(1),
2415                co2: 0,
2416                temperature: 20.0,
2417                pressure: 1010.0,
2418                humidity: 50,
2419                radon: Some(200),
2420                radiation_rate: None,
2421                radiation_total: None,
2422            },
2423        ];
2424
2425        store.insert_history("radon-device", &records).unwrap();
2426
2427        let query = HistoryQuery::new();
2428        let stats = store.history_stats(&query).unwrap();
2429
2430        assert_eq!(stats.count, 2);
2431        assert_eq!(stats.min.radon, Some(100.0));
2432        assert_eq!(stats.max.radon, Some(200.0));
2433        assert_eq!(stats.avg.radon, Some(150.0));
2434    }
2435
2436    #[test]
2437    fn test_history_stats_time_range_values() {
2438        let store = Store::open_in_memory().unwrap();
2439
2440        // Use fixed timestamps to avoid precision issues with unix timestamp conversion
2441        use time::macros::datetime;
2442        let start = datetime!(2024-01-01 00:00:00 UTC);
2443        let end = datetime!(2024-01-08 00:00:00 UTC);
2444
2445        let records = vec![
2446            HistoryRecord {
2447                timestamp: start,
2448                co2: 700,
2449                temperature: 21.0,
2450                pressure: 1011.0,
2451                humidity: 45,
2452                radon: None,
2453                radiation_rate: None,
2454                radiation_total: None,
2455            },
2456            HistoryRecord {
2457                timestamp: end,
2458                co2: 900,
2459                temperature: 23.0,
2460                pressure: 1013.0,
2461                humidity: 55,
2462                radon: None,
2463                radiation_rate: None,
2464                radiation_total: None,
2465            },
2466        ];
2467
2468        store.insert_history("test-device", &records).unwrap();
2469
2470        let query = HistoryQuery::new();
2471        let stats = store.history_stats(&query).unwrap();
2472
2473        let (min_ts, max_ts) = stats.time_range.unwrap();
2474        assert_eq!(min_ts, start);
2475        assert_eq!(max_ts, end);
2476    }
2477
2478    // ==================== Export Tests ====================
2479
2480    #[test]
2481    fn test_export_history_csv_empty() {
2482        let store = Store::open_in_memory().unwrap();
2483
2484        let query = HistoryQuery::new();
2485        let csv = store.export_history_csv(&query).unwrap();
2486
2487        assert!(csv.starts_with("timestamp,device_id,co2,temperature,pressure,humidity,radon,radiation_rate,radiation_total\n"));
2488        // Only header, no data
2489        assert_eq!(csv.lines().count(), 1);
2490    }
2491
2492    #[test]
2493    fn test_export_history_csv_with_data() {
2494        let store = Store::open_in_memory().unwrap();
2495
2496        let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
24972024-01-15T10:30:00Z,test-device,800,22.5,1013.25,45,
2498"#;
2499        store.import_history_csv(csv_data).unwrap();
2500
2501        let query = HistoryQuery::new();
2502        let csv = store.export_history_csv(&query).unwrap();
2503
2504        assert!(csv.contains("test-device"));
2505        assert!(csv.contains("800"));
2506        assert!(csv.contains("22.5"));
2507        assert!(csv.contains("1013.25"));
2508        assert!(csv.contains("45"));
2509    }
2510
2511    #[test]
2512    fn test_export_history_csv_with_radon() {
2513        let store = Store::open_in_memory().unwrap();
2514
2515        let now = OffsetDateTime::now_utc();
2516        let records = vec![HistoryRecord {
2517            timestamp: now,
2518            co2: 0,
2519            temperature: 20.0,
2520            pressure: 1010.0,
2521            humidity: 50,
2522            radon: Some(150),
2523            radiation_rate: None,
2524            radiation_total: None,
2525        }];
2526
2527        store.insert_history("radon-device", &records).unwrap();
2528
2529        let query = HistoryQuery::new();
2530        let csv = store.export_history_csv(&query).unwrap();
2531
2532        assert!(csv.contains("150"));
2533    }
2534
2535    #[test]
2536    fn test_export_history_csv_format() {
2537        let store = Store::open_in_memory().unwrap();
2538
2539        let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
25402024-01-15T10:30:00Z,device-1,800,22.5,1013.25,45,
25412024-01-15T11:30:00Z,device-1,850,23.0,1014.00,48,
2542"#;
2543        store.import_history_csv(csv_data).unwrap();
2544
2545        let query = HistoryQuery::new().oldest_first();
2546        let csv = store.export_history_csv(&query).unwrap();
2547
2548        let lines: Vec<&str> = csv.lines().collect();
2549        assert_eq!(lines.len(), 3); // header + 2 records
2550
2551        // Check header
2552        assert!(lines[0].contains("timestamp"));
2553        assert!(lines[0].contains("device_id"));
2554        assert!(lines[0].contains("co2"));
2555
2556        // Check data ordering (oldest first)
2557        assert!(lines[1].contains("800"));
2558        assert!(lines[2].contains("850"));
2559    }
2560
2561    #[test]
2562    fn test_export_history_json_empty() {
2563        let store = Store::open_in_memory().unwrap();
2564
2565        let query = HistoryQuery::new();
2566        let json = store.export_history_json(&query).unwrap();
2567
2568        assert_eq!(json.trim(), "[]");
2569    }
2570
2571    #[test]
2572    fn test_export_history_json_with_data() {
2573        let store = Store::open_in_memory().unwrap();
2574
2575        let now = OffsetDateTime::now_utc();
2576        let records = vec![HistoryRecord {
2577            timestamp: now,
2578            co2: 800,
2579            temperature: 22.5,
2580            pressure: 1013.0,
2581            humidity: 45,
2582            radon: None,
2583            radiation_rate: None,
2584            radiation_total: None,
2585        }];
2586
2587        store.insert_history("test-device", &records).unwrap();
2588
2589        let query = HistoryQuery::new();
2590        let json = store.export_history_json(&query).unwrap();
2591
2592        // Parse and verify
2593        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
2594        assert_eq!(parsed.len(), 1);
2595        assert_eq!(parsed[0]["device_id"], "test-device");
2596        assert_eq!(parsed[0]["co2"], 800);
2597    }
2598
2599    #[test]
2600    fn test_export_import_json_roundtrip() {
2601        let store = Store::open_in_memory().unwrap();
2602
2603        let now = OffsetDateTime::now_utc();
2604        let original_records = vec![
2605            HistoryRecord {
2606                timestamp: now,
2607                co2: 750,
2608                temperature: 21.5,
2609                pressure: 1012.0,
2610                humidity: 48,
2611                radon: None,
2612                radiation_rate: None,
2613                radiation_total: None,
2614            },
2615            HistoryRecord {
2616                timestamp: now + time::Duration::hours(1),
2617                co2: 850,
2618                temperature: 22.5,
2619                pressure: 1013.0,
2620                humidity: 52,
2621                radon: None,
2622                radiation_rate: None,
2623                radiation_total: None,
2624            },
2625        ];
2626
2627        store
2628            .insert_history("roundtrip-device", &original_records)
2629            .unwrap();
2630
2631        // Export
2632        let query = HistoryQuery::new()
2633            .device("roundtrip-device")
2634            .oldest_first();
2635        let json = store.export_history_json(&query).unwrap();
2636
2637        // Create new store and import
2638        let store2 = Store::open_in_memory().unwrap();
2639        let result = store2.import_history_json(&json).unwrap();
2640
2641        assert_eq!(result.imported, 2);
2642
2643        // Verify data matches
2644        let records = store2.query_history(&query).unwrap();
2645        assert_eq!(records.len(), 2);
2646        assert_eq!(records[0].co2, 750);
2647        assert_eq!(records[1].co2, 850);
2648    }
2649
2650    // ==================== Query Tests ====================
2651
2652    #[test]
2653    fn test_query_readings_with_pagination() {
2654        let store = Store::open_in_memory().unwrap();
2655
2656        // Insert 10 readings
2657        for i in 0..10 {
2658            let mut reading = create_test_reading();
2659            reading.co2 = 700 + i * 10;
2660            store.insert_reading("paginated-device", &reading).unwrap();
2661        }
2662
2663        // Query with limit and offset
2664        let query = ReadingQuery::new()
2665            .device("paginated-device")
2666            .oldest_first()
2667            .limit(3)
2668            .offset(2);
2669
2670        let readings = store.query_readings(&query).unwrap();
2671        assert_eq!(readings.len(), 3);
2672        assert_eq!(readings[0].co2, 720); // 3rd reading (offset 2)
2673        assert_eq!(readings[2].co2, 740); // 5th reading
2674    }
2675
2676    #[test]
2677    fn test_query_readings_time_range() {
2678        let store = Store::open_in_memory().unwrap();
2679
2680        let base_time = OffsetDateTime::now_utc();
2681
2682        // Insert readings at different times
2683        let mut reading1 = create_test_reading();
2684        reading1.captured_at = Some(base_time - time::Duration::days(2));
2685        reading1.co2 = 600;
2686        store.insert_reading("time-device", &reading1).unwrap();
2687
2688        let mut reading2 = create_test_reading();
2689        reading2.captured_at = Some(base_time - time::Duration::hours(1));
2690        reading2.co2 = 800;
2691        store.insert_reading("time-device", &reading2).unwrap();
2692
2693        let mut reading3 = create_test_reading();
2694        reading3.captured_at = Some(base_time);
2695        reading3.co2 = 900;
2696        store.insert_reading("time-device", &reading3).unwrap();
2697
2698        // Query last day only
2699        let query = ReadingQuery::new()
2700            .device("time-device")
2701            .since(base_time - time::Duration::days(1));
2702
2703        let readings = store.query_readings(&query).unwrap();
2704        assert_eq!(readings.len(), 2);
2705    }
2706
2707    #[test]
2708    fn test_query_history_with_pagination() {
2709        let store = Store::open_in_memory().unwrap();
2710
2711        let base_time = OffsetDateTime::now_utc();
2712        let records: Vec<_> = (0..10)
2713            .map(|i| HistoryRecord {
2714                timestamp: base_time + time::Duration::hours(i),
2715                co2: 700 + (i as u16) * 10,
2716                temperature: 22.0,
2717                pressure: 1013.0,
2718                humidity: 50,
2719                radon: None,
2720                radiation_rate: None,
2721                radiation_total: None,
2722            })
2723            .collect();
2724
2725        store.insert_history("paginated-device", &records).unwrap();
2726
2727        // Query with limit and offset
2728        let query = HistoryQuery::new()
2729            .device("paginated-device")
2730            .oldest_first()
2731            .limit(3)
2732            .offset(2);
2733
2734        let results = store.query_history(&query).unwrap();
2735        assert_eq!(results.len(), 3);
2736        assert_eq!(results[0].co2, 720);
2737        assert_eq!(results[2].co2, 740);
2738    }
2739
2740    // ==================== Device Tests ====================
2741
2742    #[test]
2743    fn test_update_device_info() {
2744        let store = Store::open_in_memory().unwrap();
2745        store.upsert_device("info-device", None).unwrap();
2746
2747        let info = aranet_types::DeviceInfo {
2748            name: "My Aranet4".to_string(),
2749            model: "Aranet4".to_string(),
2750            serial: "ABC123".to_string(),
2751            firmware: "v1.2.0".to_string(),
2752            hardware: "1.0".to_string(),
2753            ..Default::default()
2754        };
2755
2756        store.update_device_info("info-device", &info).unwrap();
2757
2758        let device = store.get_device("info-device").unwrap().unwrap();
2759        assert_eq!(device.name, Some("My Aranet4".to_string()));
2760        assert_eq!(device.serial, Some("ABC123".to_string()));
2761        assert_eq!(device.firmware, Some("v1.2.0".to_string()));
2762        assert_eq!(device.device_type, Some(aranet_types::DeviceType::Aranet4));
2763    }
2764
2765    #[test]
2766    fn test_update_device_info_aranet2() {
2767        let store = Store::open_in_memory().unwrap();
2768        store.upsert_device("aranet2-device", None).unwrap();
2769
2770        let info = aranet_types::DeviceInfo {
2771            name: "My Aranet2".to_string(),
2772            model: "Aranet2".to_string(),
2773            serial: "XYZ789".to_string(),
2774            firmware: "v2.0.0".to_string(),
2775            hardware: "2.0".to_string(),
2776            ..Default::default()
2777        };
2778
2779        store.update_device_info("aranet2-device", &info).unwrap();
2780
2781        let device = store.get_device("aranet2-device").unwrap().unwrap();
2782        assert_eq!(device.device_type, Some(aranet_types::DeviceType::Aranet2));
2783    }
2784
2785    #[test]
2786    fn test_update_device_info_radon() {
2787        let store = Store::open_in_memory().unwrap();
2788        store.upsert_device("radon-device", None).unwrap();
2789
2790        let info = aranet_types::DeviceInfo {
2791            name: "My AranetRn+".to_string(),
2792            model: "AranetRn+ Radon".to_string(),
2793            serial: "RAD001".to_string(),
2794            firmware: "v1.0.0".to_string(),
2795            hardware: "1.0".to_string(),
2796            ..Default::default()
2797        };
2798
2799        store.update_device_info("radon-device", &info).unwrap();
2800
2801        let device = store.get_device("radon-device").unwrap().unwrap();
2802        assert_eq!(
2803            device.device_type,
2804            Some(aranet_types::DeviceType::AranetRadon)
2805        );
2806    }
2807
2808    #[test]
2809    fn test_update_device_metadata() {
2810        let store = Store::open_in_memory().unwrap();
2811        store.upsert_device("meta-device", None).unwrap();
2812
2813        store
2814            .update_device_metadata(
2815                "meta-device",
2816                Some("Kitchen Sensor"),
2817                Some(aranet_types::DeviceType::Aranet4),
2818            )
2819            .unwrap();
2820
2821        let device = store.get_device("meta-device").unwrap().unwrap();
2822        assert_eq!(device.name, Some("Kitchen Sensor".to_string()));
2823        assert_eq!(device.device_type, Some(aranet_types::DeviceType::Aranet4));
2824    }
2825
2826    #[test]
2827    fn test_list_devices_ordered_by_last_seen() {
2828        let store = Store::open_in_memory().unwrap();
2829
2830        // Insert devices and verify ordering
2831        // We'll use a longer sleep to ensure timestamp differences
2832        store.upsert_device("device-a", Some("First")).unwrap();
2833        std::thread::sleep(std::time::Duration::from_secs(1));
2834        store.upsert_device("device-b", Some("Second")).unwrap();
2835        std::thread::sleep(std::time::Duration::from_secs(1));
2836        store.upsert_device("device-c", Some("Third")).unwrap();
2837
2838        let devices = store.list_devices().unwrap();
2839        assert_eq!(devices.len(), 3);
2840
2841        // Verify devices are ordered by last_seen DESC (most recent first)
2842        // Since timestamps are stored as unix timestamps (seconds),
2843        // we need 1+ second sleep between inserts
2844        assert!(devices[0].last_seen >= devices[1].last_seen);
2845        assert!(devices[1].last_seen >= devices[2].last_seen);
2846    }
2847
2848    #[test]
2849    fn test_count_readings() {
2850        let store = Store::open_in_memory().unwrap();
2851
2852        // Insert readings for multiple devices
2853        for _ in 0..5 {
2854            store
2855                .insert_reading("device-1", &create_test_reading())
2856                .unwrap();
2857        }
2858        for _ in 0..3 {
2859            store
2860                .insert_reading("device-2", &create_test_reading())
2861                .unwrap();
2862        }
2863
2864        // Count for specific device
2865        assert_eq!(store.count_readings(Some("device-1")).unwrap(), 5);
2866        assert_eq!(store.count_readings(Some("device-2")).unwrap(), 3);
2867        assert_eq!(store.count_readings(Some("nonexistent")).unwrap(), 0);
2868
2869        // Count all
2870        assert_eq!(store.count_readings(None).unwrap(), 8);
2871    }
2872
2873    #[test]
2874    fn test_count_history() {
2875        let store = Store::open_in_memory().unwrap();
2876
2877        let now = OffsetDateTime::now_utc();
2878
2879        // Insert history for multiple devices
2880        let records: Vec<_> = (0..5)
2881            .map(|i| HistoryRecord {
2882                timestamp: now + time::Duration::hours(i),
2883                co2: 800,
2884                temperature: 22.0,
2885                pressure: 1013.0,
2886                humidity: 50,
2887                radon: None,
2888                radiation_rate: None,
2889                radiation_total: None,
2890            })
2891            .collect();
2892
2893        store.insert_history("device-1", &records).unwrap();
2894        store.insert_history("device-2", &records[..3]).unwrap();
2895
2896        assert_eq!(store.count_history(Some("device-1")).unwrap(), 5);
2897        assert_eq!(store.count_history(Some("device-2")).unwrap(), 3);
2898        assert_eq!(store.count_history(None).unwrap(), 8);
2899    }
2900
2901    // ==================== Edge Cases ====================
2902
2903    #[test]
2904    fn test_reading_with_all_sensor_types() {
2905        let store = Store::open_in_memory().unwrap();
2906
2907        // Aranet4 reading
2908        let reading = create_test_reading();
2909        store.insert_reading("aranet4", &reading).unwrap();
2910
2911        // Radon reading
2912        let mut radon_reading = create_test_reading();
2913        radon_reading.co2 = 0;
2914        radon_reading.radon = Some(150);
2915        store.insert_reading("aranet-rn", &radon_reading).unwrap();
2916
2917        // Radiation reading
2918        let mut rad_reading = create_test_reading();
2919        rad_reading.co2 = 0;
2920        rad_reading.radiation_rate = Some(0.12);
2921        rad_reading.radiation_total = Some(0.003);
2922        store.insert_reading("aranet-rad", &rad_reading).unwrap();
2923
2924        // Query each device
2925        let aranet4_readings = store
2926            .query_readings(&ReadingQuery::new().device("aranet4"))
2927            .unwrap();
2928        assert_eq!(aranet4_readings.len(), 1);
2929        assert_eq!(aranet4_readings[0].co2, 800);
2930
2931        let radon_readings = store
2932            .query_readings(&ReadingQuery::new().device("aranet-rn"))
2933            .unwrap();
2934        assert_eq!(radon_readings.len(), 1);
2935        assert_eq!(radon_readings[0].radon, Some(150));
2936
2937        let rad_readings = store
2938            .query_readings(&ReadingQuery::new().device("aranet-rad"))
2939            .unwrap();
2940        assert_eq!(rad_readings.len(), 1);
2941        assert_eq!(rad_readings[0].radiation_rate, Some(0.12));
2942    }
2943
2944    #[test]
2945    fn test_device_not_found_error() {
2946        let store = Store::open_in_memory().unwrap();
2947
2948        // This should fail because the device doesn't exist
2949        // and we're not using upsert
2950        let result = store.get_device("nonexistent");
2951        assert!(result.unwrap().is_none());
2952    }
2953
2954    #[test]
2955    fn test_empty_device_name() {
2956        let store = Store::open_in_memory().unwrap();
2957
2958        // Empty name should be treated as None
2959        let info = aranet_types::DeviceInfo {
2960            name: "".to_string(),
2961            model: "Aranet4".to_string(),
2962            ..Default::default()
2963        };
2964
2965        store.upsert_device("empty-name-device", None).unwrap();
2966        store
2967            .update_device_info("empty-name-device", &info)
2968            .unwrap();
2969
2970        let device = store.get_device("empty-name-device").unwrap().unwrap();
2971        // Name should remain None since we passed empty string
2972        assert!(device.name.is_none());
2973    }
2974
2975    #[test]
2976    fn test_import_csv_invalid_json() {
2977        let store = Store::open_in_memory().unwrap();
2978
2979        let result = store.import_history_json("not valid json");
2980        assert!(result.is_err());
2981    }
2982
2983    #[test]
2984    fn test_reading_with_all_status_types() {
2985        let store = Store::open_in_memory().unwrap();
2986
2987        for status in [Status::Green, Status::Yellow, Status::Red, Status::Error] {
2988            let mut reading = create_test_reading();
2989            reading.status = status;
2990            let device_id = format!("status-{:?}", status);
2991            store.insert_reading(&device_id, &reading).unwrap();
2992
2993            let stored = store.get_latest_reading(&device_id).unwrap().unwrap();
2994            assert_eq!(stored.status, status);
2995        }
2996    }
2997
2998    // ==================== Concurrent Access Tests ====================
2999    //
3000    // These tests verify the store behaves correctly when accessed concurrently
3001    // through a Mutex, simulating the real-world usage in aranet-service.
3002
3003    #[tokio::test]
3004    async fn test_concurrent_reading_inserts() {
3005        use std::sync::Arc;
3006        use tokio::sync::Mutex;
3007
3008        let store = Arc::new(Mutex::new(Store::open_in_memory().unwrap()));
3009
3010        // Spawn 10 concurrent tasks, each inserting 10 readings
3011        let mut handles = Vec::new();
3012        for task_id in 0..10 {
3013            let store = Arc::clone(&store);
3014            handles.push(tokio::spawn(async move {
3015                for i in 0..10 {
3016                    let reading = CurrentReading {
3017                        co2: 400 + (task_id * 100) + i,
3018                        temperature: 20.0 + (task_id as f32),
3019                        pressure: 1013.0,
3020                        humidity: 50,
3021                        battery: 85,
3022                        status: Status::Green,
3023                        interval: 60,
3024                        age: 0,
3025                        captured_at: Some(OffsetDateTime::now_utc()),
3026                        radon: None,
3027                        radiation_rate: None,
3028                        radiation_total: None,
3029                        radon_avg_24h: None,
3030                        radon_avg_7d: None,
3031                        radon_avg_30d: None,
3032                    };
3033                    let device_id = format!("concurrent-device-{}", task_id);
3034                    let guard = store.lock().await;
3035                    guard.insert_reading(&device_id, &reading).unwrap();
3036                }
3037            }));
3038        }
3039
3040        // Wait for all tasks to complete
3041        for handle in handles {
3042            handle.await.unwrap();
3043        }
3044
3045        // Verify all readings were inserted
3046        let guard = store.lock().await;
3047        let total = guard.count_readings(None).unwrap();
3048        assert_eq!(total, 100); // 10 tasks * 10 readings each
3049    }
3050
3051    #[tokio::test]
3052    async fn test_concurrent_reads_and_writes() {
3053        use std::sync::Arc;
3054        use tokio::sync::Mutex;
3055
3056        let store = Arc::new(Mutex::new(Store::open_in_memory().unwrap()));
3057
3058        // Pre-populate with some data
3059        {
3060            let guard = store.lock().await;
3061            for i in 0..10 {
3062                let reading = CurrentReading {
3063                    co2: 500 + i * 50,
3064                    temperature: 22.0,
3065                    pressure: 1013.0,
3066                    humidity: 50,
3067                    battery: 85,
3068                    status: Status::Green,
3069                    interval: 60,
3070                    age: 0,
3071                    captured_at: Some(OffsetDateTime::now_utc()),
3072                    radon: None,
3073                    radiation_rate: None,
3074                    radiation_total: None,
3075                    radon_avg_24h: None,
3076                    radon_avg_7d: None,
3077                    radon_avg_30d: None,
3078                };
3079                guard.insert_reading("shared-device", &reading).unwrap();
3080            }
3081        }
3082
3083        // Spawn concurrent readers and writers
3084        let mut handles = Vec::new();
3085
3086        // 5 reader tasks
3087        for _ in 0..5 {
3088            let store = Arc::clone(&store);
3089            handles.push(tokio::spawn(async move {
3090                for _ in 0..10 {
3091                    let guard = store.lock().await;
3092                    let readings = guard
3093                        .query_readings(&ReadingQuery::new().device("shared-device"))
3094                        .unwrap();
3095                    assert!(!readings.is_empty());
3096                    drop(guard);
3097                    tokio::task::yield_now().await;
3098                }
3099            }));
3100        }
3101
3102        // 3 writer tasks
3103        for task_id in 0..3 {
3104            let store = Arc::clone(&store);
3105            handles.push(tokio::spawn(async move {
3106                for i in 0..5 {
3107                    let reading = CurrentReading {
3108                        co2: 1000 + (task_id * 100) + i,
3109                        temperature: 25.0,
3110                        pressure: 1015.0,
3111                        humidity: 55,
3112                        battery: 80,
3113                        status: Status::Yellow,
3114                        interval: 60,
3115                        age: 0,
3116                        captured_at: Some(OffsetDateTime::now_utc()),
3117                        radon: None,
3118                        radiation_rate: None,
3119                        radiation_total: None,
3120                        radon_avg_24h: None,
3121                        radon_avg_7d: None,
3122                        radon_avg_30d: None,
3123                    };
3124                    let guard = store.lock().await;
3125                    guard.insert_reading("shared-device", &reading).unwrap();
3126                    drop(guard);
3127                    tokio::task::yield_now().await;
3128                }
3129            }));
3130        }
3131
3132        // Wait for all tasks
3133        for handle in handles {
3134            handle.await.unwrap();
3135        }
3136
3137        // Verify final state
3138        let guard = store.lock().await;
3139        let total = guard.count_readings(Some("shared-device")).unwrap();
3140        assert_eq!(total, 10 + (3 * 5)); // Initial 10 + 3 writers * 5 each = 25
3141    }
3142
3143    #[tokio::test]
3144    async fn test_concurrent_device_upserts() {
3145        use std::sync::Arc;
3146        use tokio::sync::Mutex;
3147
3148        let store = Arc::new(Mutex::new(Store::open_in_memory().unwrap()));
3149
3150        // Spawn tasks that upsert the same device concurrently
3151        let mut handles = Vec::new();
3152        for i in 0..20 {
3153            let store = Arc::clone(&store);
3154            handles.push(tokio::spawn(async move {
3155                let guard = store.lock().await;
3156                guard
3157                    .upsert_device("contested-device", Some(&format!("Name-{}", i)))
3158                    .unwrap();
3159            }));
3160        }
3161
3162        for handle in handles {
3163            handle.await.unwrap();
3164        }
3165
3166        // Device should exist with one of the names
3167        let guard = store.lock().await;
3168        let device = guard.get_device("contested-device").unwrap().unwrap();
3169        assert!(device.name.unwrap().starts_with("Name-"));
3170    }
3171}