things3_core/database/
core.rs

1use crate::{
2    database::{mappers::map_task_row, query_builders::TaskUpdateBuilder, validators},
3    error::{Result as ThingsResult, ThingsError},
4    models::{
5        Area, CreateTaskRequest, DeleteChildHandling, Project, Task, TaskStatus, TaskType,
6        UpdateTaskRequest,
7    },
8};
9use chrono::{DateTime, NaiveDate, Utc};
10use serde::{Deserialize, Serialize};
11use sqlx::{pool::PoolOptions, Row, SqlitePool};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14use tracing::{debug, error, info, instrument};
15use uuid::Uuid;
16
17/// Convert f64 timestamp to i64 safely
18pub(crate) fn safe_timestamp_convert(ts_f64: f64) -> i64 {
19    // Use try_from to avoid clippy warnings about casting
20    if ts_f64.is_finite() && ts_f64 >= 0.0 {
21        // Use a reasonable upper bound for timestamps (year 2100)
22        let max_timestamp = 4_102_444_800_f64; // 2100-01-01 00:00:00 UTC
23        if ts_f64 <= max_timestamp {
24            // Convert via string to avoid precision loss warnings
25            let ts_str = format!("{:.0}", ts_f64.trunc());
26            ts_str.parse::<i64>().unwrap_or(0)
27        } else {
28            0 // Use epoch if too large
29        }
30    } else {
31        0 // Use epoch if invalid
32    }
33}
34
35/// Convert Things 3 date value (seconds since 2001-01-01) to NaiveDate
36pub(crate) fn things_date_to_naive_date(seconds_since_2001: i64) -> Option<chrono::NaiveDate> {
37    use chrono::{TimeZone, Utc};
38
39    if seconds_since_2001 <= 0 {
40        return None;
41    }
42
43    // Base date: 2001-01-01 00:00:00 UTC
44    let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
45
46    // Add seconds to get the actual date
47    let date_time = base_date + chrono::Duration::seconds(seconds_since_2001);
48
49    Some(date_time.date_naive())
50}
51
52/// Convert NaiveDate to Things 3 timestamp (seconds since 2001-01-01)
53pub fn naive_date_to_things_timestamp(date: NaiveDate) -> i64 {
54    use chrono::{NaiveTime, TimeZone, Utc};
55
56    // Base date: 2001-01-01 00:00:00 UTC
57    let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
58
59    // Convert NaiveDate to DateTime at midnight UTC
60    let date_time = date
61        .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
62        .and_local_timezone(Utc)
63        .single()
64        .unwrap();
65
66    // Calculate seconds difference
67    date_time.timestamp() - base_date.timestamp()
68}
69
70/// Serialize tags to Things 3 binary format
71/// Note: This is a simplified implementation using JSON
72/// The actual Things 3 binary format is proprietary
73pub fn serialize_tags_to_blob(tags: &[String]) -> ThingsResult<Vec<u8>> {
74    serde_json::to_vec(tags)
75        .map_err(|e| ThingsError::unknown(format!("Failed to serialize tags: {e}")))
76}
77
78/// Deserialize tags from Things 3 binary format
79pub fn deserialize_tags_from_blob(blob: &[u8]) -> ThingsResult<Vec<String>> {
80    serde_json::from_slice(blob)
81        .map_err(|e| ThingsError::unknown(format!("Failed to deserialize tags: {e}")))
82}
83
84/// Convert Things 3 UUID format to standard UUID
85/// Things 3 uses base64-like strings, we'll generate a UUID from the hash
86pub(crate) fn things_uuid_to_uuid(things_uuid: &str) -> Uuid {
87    // For now, create a deterministic UUID from the Things 3 ID
88    // This ensures consistent mapping between Things 3 IDs and UUIDs
89    use std::collections::hash_map::DefaultHasher;
90    use std::hash::{Hash, Hasher};
91
92    let mut hasher = DefaultHasher::new();
93    things_uuid.hash(&mut hasher);
94    let hash = hasher.finish();
95
96    // Create a UUID from the hash (not cryptographically secure, but consistent)
97    // Use proper byte extraction without truncation warnings
98    let bytes = [
99        ((hash >> 56) & 0xFF) as u8,
100        ((hash >> 48) & 0xFF) as u8,
101        ((hash >> 40) & 0xFF) as u8,
102        ((hash >> 32) & 0xFF) as u8,
103        ((hash >> 24) & 0xFF) as u8,
104        ((hash >> 16) & 0xFF) as u8,
105        ((hash >> 8) & 0xFF) as u8,
106        (hash & 0xFF) as u8,
107        // Fill remaining bytes with a pattern based on the string
108        u8::try_from(things_uuid.len().min(255)).unwrap_or(255),
109        things_uuid.chars().next().unwrap_or('0') as u8,
110        things_uuid.chars().nth(1).unwrap_or('0') as u8,
111        things_uuid.chars().nth(2).unwrap_or('0') as u8,
112        things_uuid.chars().nth(3).unwrap_or('0') as u8,
113        things_uuid.chars().nth(4).unwrap_or('0') as u8,
114        things_uuid.chars().nth(5).unwrap_or('0') as u8,
115        things_uuid.chars().nth(6).unwrap_or('0') as u8,
116    ];
117
118    Uuid::from_bytes(bytes)
119}
120
121impl TaskStatus {
122    fn from_i32(value: i32) -> Option<Self> {
123        match value {
124            0 => Some(TaskStatus::Incomplete),
125            1 => Some(TaskStatus::Completed),
126            2 => Some(TaskStatus::Canceled),
127            3 => Some(TaskStatus::Trashed),
128            _ => None,
129        }
130    }
131}
132
133impl TaskType {
134    fn from_i32(value: i32) -> Option<Self> {
135        match value {
136            0 => Some(TaskType::Todo),
137            1 => Some(TaskType::Project),
138            2 => Some(TaskType::Heading),
139            3 => Some(TaskType::Area),
140            _ => None,
141        }
142    }
143}
144
145/// Database connection pool configuration for optimal performance
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct DatabasePoolConfig {
148    /// Maximum number of connections in the pool
149    pub max_connections: u32,
150    /// Minimum number of connections in the pool
151    pub min_connections: u32,
152    /// Connection timeout
153    pub connect_timeout: Duration,
154    /// Idle timeout for connections
155    pub idle_timeout: Duration,
156    /// Maximum lifetime of a connection
157    pub max_lifetime: Duration,
158    /// Test connections before use
159    pub test_before_acquire: bool,
160    /// SQLite-specific optimizations
161    pub sqlite_optimizations: SqliteOptimizations,
162}
163
164/// SQLite-specific optimization settings
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct SqliteOptimizations {
167    /// Enable WAL mode for better concurrency
168    pub enable_wal_mode: bool,
169    /// Set synchronous mode (NORMAL, FULL, OFF)
170    pub synchronous_mode: String,
171    /// Cache size in pages (negative = KB)
172    pub cache_size: i32,
173    /// Enable foreign key constraints
174    pub enable_foreign_keys: bool,
175    /// Set journal mode
176    pub journal_mode: String,
177    /// Set temp store (MEMORY, FILE, DEFAULT)
178    pub temp_store: String,
179    /// Set mmap size for better performance
180    pub mmap_size: i64,
181    /// Enable query planner optimizations
182    pub enable_query_planner: bool,
183}
184
185impl Default for DatabasePoolConfig {
186    fn default() -> Self {
187        Self {
188            max_connections: 10,
189            min_connections: 1,
190            connect_timeout: Duration::from_secs(30),
191            idle_timeout: Duration::from_secs(600), // 10 minutes
192            max_lifetime: Duration::from_secs(1800), // 30 minutes
193            test_before_acquire: true,
194            sqlite_optimizations: SqliteOptimizations::default(),
195        }
196    }
197}
198
199impl Default for SqliteOptimizations {
200    fn default() -> Self {
201        Self {
202            enable_wal_mode: true,
203            synchronous_mode: "NORMAL".to_string(),
204            cache_size: -20000, // 20MB cache
205            enable_foreign_keys: true,
206            journal_mode: "WAL".to_string(),
207            temp_store: "MEMORY".to_string(),
208            mmap_size: 268_435_456, // 256MB
209            enable_query_planner: true,
210        }
211    }
212}
213
214/// Connection pool health status
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct PoolHealthStatus {
217    pub is_healthy: bool,
218    pub pool_size: u32,
219    pub active_connections: u32,
220    pub idle_connections: u32,
221    pub max_connections: u32,
222    pub min_connections: u32,
223    pub connection_timeout: Duration,
224    pub idle_timeout: Option<Duration>,
225    pub max_lifetime: Option<Duration>,
226}
227
228/// Detailed connection pool metrics
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct PoolMetrics {
231    pub pool_size: u32,
232    pub active_connections: u32,
233    pub idle_connections: u32,
234    pub max_connections: u32,
235    pub min_connections: u32,
236    pub utilization_percentage: f64,
237    pub is_healthy: bool,
238    pub response_time_ms: u64,
239    pub connection_timeout: Duration,
240    pub idle_timeout: Option<Duration>,
241    pub max_lifetime: Option<Duration>,
242}
243
244/// Comprehensive health status including pool and database
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ComprehensiveHealthStatus {
247    pub overall_healthy: bool,
248    pub pool_health: PoolHealthStatus,
249    pub pool_metrics: PoolMetrics,
250    pub database_stats: DatabaseStats,
251    pub timestamp: DateTime<Utc>,
252}
253
254/// SQLx-based database implementation for Things 3 data
255/// This provides async, Send + Sync compatible database access
256#[derive(Debug, Clone)]
257pub struct ThingsDatabase {
258    pool: SqlitePool,
259    config: DatabasePoolConfig,
260}
261
262impl ThingsDatabase {
263    /// Create a new database connection pool with default configuration
264    ///
265    /// # Examples
266    ///
267    /// ```no_run
268    /// use things3_core::{ThingsDatabase, ThingsError};
269    /// use std::path::Path;
270    ///
271    /// # async fn example() -> Result<(), ThingsError> {
272    /// // Connect to Things 3 database
273    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
274    ///
275    /// // Get inbox tasks
276    /// let tasks = db.get_inbox(None).await?;
277    /// println!("Found {} tasks in inbox", tasks.len());
278    /// # Ok(())
279    /// # }
280    /// ```
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if the database connection fails or if `SQLite` configuration fails
285    #[instrument]
286    pub async fn new(database_path: &Path) -> ThingsResult<Self> {
287        Self::new_with_config(database_path, DatabasePoolConfig::default()).await
288    }
289
290    /// Create a new database connection pool with custom configuration
291    ///
292    /// # Examples
293    ///
294    /// ```no_run
295    /// use things3_core::{ThingsDatabase, DatabasePoolConfig, ThingsError};
296    /// use std::path::Path;
297    /// use std::time::Duration;
298    ///
299    /// # async fn example() -> Result<(), ThingsError> {
300    /// // Create custom pool configuration
301    /// let config = DatabasePoolConfig {
302    ///     max_connections: 10,
303    ///     min_connections: 2,
304    ///     connect_timeout: Duration::from_secs(5),
305    ///     idle_timeout: Duration::from_secs(300),
306    ///     max_lifetime: Duration::from_secs(3600),
307    ///     test_before_acquire: true,
308    ///     sqlite_optimizations: Default::default(),
309    /// };
310    ///
311    /// // Connect with custom configuration
312    /// let db = ThingsDatabase::new_with_config(
313    ///     Path::new("/path/to/things.db"),
314    ///     config,
315    /// ).await?;
316    /// # Ok(())
317    /// # }
318    /// ```
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if the database connection fails or if `SQLite` configuration fails
323    #[instrument]
324    pub async fn new_with_config(
325        database_path: &Path,
326        config: DatabasePoolConfig,
327    ) -> ThingsResult<Self> {
328        let database_url = format!("sqlite:{}", database_path.display());
329
330        info!(
331            "Connecting to SQLite database at: {} with optimized pool",
332            database_url
333        );
334
335        // Create optimized connection pool
336        let pool = PoolOptions::new()
337            .max_connections(config.max_connections)
338            .min_connections(config.min_connections)
339            .acquire_timeout(config.connect_timeout)
340            .idle_timeout(Some(config.idle_timeout))
341            .max_lifetime(Some(config.max_lifetime))
342            .test_before_acquire(config.test_before_acquire)
343            .connect(&database_url)
344            .await
345            .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
346
347        // Apply SQLite optimizations
348        Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
349
350        info!(
351            "Database connection pool established successfully with {} max connections",
352            config.max_connections
353        );
354
355        Ok(Self { pool, config })
356    }
357
358    /// Apply SQLite-specific optimizations
359    async fn apply_sqlite_optimizations(
360        pool: &SqlitePool,
361        optimizations: &SqliteOptimizations,
362    ) -> ThingsResult<()> {
363        // Set journal mode
364        sqlx::query(&format!(
365            "PRAGMA journal_mode = {}",
366            optimizations.journal_mode
367        ))
368        .execute(pool)
369        .await
370        .map_err(|e| ThingsError::unknown(format!("Failed to set journal mode: {e}")))?;
371
372        // Set synchronous mode
373        sqlx::query(&format!(
374            "PRAGMA synchronous = {}",
375            optimizations.synchronous_mode
376        ))
377        .execute(pool)
378        .await
379        .map_err(|e| ThingsError::unknown(format!("Failed to set synchronous mode: {e}")))?;
380
381        // Set cache size
382        sqlx::query(&format!("PRAGMA cache_size = {}", optimizations.cache_size))
383            .execute(pool)
384            .await
385            .map_err(|e| ThingsError::unknown(format!("Failed to set cache size: {e}")))?;
386
387        // Set foreign keys
388        let fk_setting = if optimizations.enable_foreign_keys {
389            "ON"
390        } else {
391            "OFF"
392        };
393        sqlx::query(&format!("PRAGMA foreign_keys = {fk_setting}"))
394            .execute(pool)
395            .await
396            .map_err(|e| ThingsError::unknown(format!("Failed to set foreign keys: {e}")))?;
397
398        // Set temp store
399        sqlx::query(&format!("PRAGMA temp_store = {}", optimizations.temp_store))
400            .execute(pool)
401            .await
402            .map_err(|e| ThingsError::unknown(format!("Failed to set temp store: {e}")))?;
403
404        // Set mmap size
405        sqlx::query(&format!("PRAGMA mmap_size = {}", optimizations.mmap_size))
406            .execute(pool)
407            .await
408            .map_err(|e| ThingsError::unknown(format!("Failed to set mmap size: {e}")))?;
409
410        // Enable query planner optimizations
411        if optimizations.enable_query_planner {
412            sqlx::query("PRAGMA optimize")
413                .execute(pool)
414                .await
415                .map_err(|e| ThingsError::unknown(format!("Failed to optimize database: {e}")))?;
416        }
417
418        debug!(
419            "Applied SQLite optimizations: WAL={}, sync={}, cache={}KB, fk={}, temp={}, mmap={}MB",
420            optimizations.enable_wal_mode,
421            optimizations.synchronous_mode,
422            optimizations.cache_size.abs() / 1024,
423            optimizations.enable_foreign_keys,
424            optimizations.temp_store,
425            optimizations.mmap_size / 1024 / 1024
426        );
427
428        Ok(())
429    }
430
431    /// Create a new database connection pool from a connection string with default configuration
432    ///
433    /// # Errors
434    ///
435    /// Returns an error if the database connection fails or if `SQLite` configuration fails
436    #[instrument]
437    pub async fn from_connection_string(database_url: &str) -> ThingsResult<Self> {
438        Self::from_connection_string_with_config(database_url, DatabasePoolConfig::default()).await
439    }
440
441    /// Create a new database connection pool from a connection string with custom configuration
442    ///
443    /// # Errors
444    ///
445    /// Returns an error if the database connection fails or if `SQLite` configuration fails
446    #[instrument]
447    pub async fn from_connection_string_with_config(
448        database_url: &str,
449        config: DatabasePoolConfig,
450    ) -> ThingsResult<Self> {
451        info!(
452            "Connecting to SQLite database: {} with optimized pool",
453            database_url
454        );
455
456        // Create optimized connection pool
457        let pool = PoolOptions::new()
458            .max_connections(config.max_connections)
459            .min_connections(config.min_connections)
460            .acquire_timeout(config.connect_timeout)
461            .idle_timeout(Some(config.idle_timeout))
462            .max_lifetime(Some(config.max_lifetime))
463            .test_before_acquire(config.test_before_acquire)
464            .connect(database_url)
465            .await
466            .map_err(|e| ThingsError::unknown(format!("Failed to connect to database: {e}")))?;
467
468        // Apply SQLite optimizations
469        Self::apply_sqlite_optimizations(&pool, &config.sqlite_optimizations).await?;
470
471        info!(
472            "Database connection pool established successfully with {} max connections",
473            config.max_connections
474        );
475
476        Ok(Self { pool, config })
477    }
478
479    /// Get the underlying connection pool
480    #[must_use]
481    pub fn pool(&self) -> &SqlitePool {
482        &self.pool
483    }
484
485    /// Check if the database is connected
486    #[instrument]
487    pub async fn is_connected(&self) -> bool {
488        match sqlx::query("SELECT 1").fetch_one(&self.pool).await {
489            Ok(_) => {
490                debug!("Database connection is healthy");
491                true
492            }
493            Err(e) => {
494                error!("Database connection check failed: {}", e);
495                false
496            }
497        }
498    }
499
500    /// Get connection pool health status
501    ///
502    /// # Errors
503    ///
504    /// Returns an error if the health check fails
505    #[instrument]
506    pub async fn get_pool_health(&self) -> ThingsResult<PoolHealthStatus> {
507        let pool_size = self.pool.size();
508        let idle_connections = self.pool.num_idle();
509        let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
510
511        // Test a simple query to verify connection health
512        let is_healthy = self.is_connected().await;
513
514        Ok(PoolHealthStatus {
515            is_healthy,
516            pool_size,
517            active_connections,
518            idle_connections: u32::try_from(idle_connections).unwrap_or(0),
519            max_connections: self.config.max_connections,
520            min_connections: self.config.min_connections,
521            connection_timeout: self.config.connect_timeout,
522            idle_timeout: Some(self.config.idle_timeout),
523            max_lifetime: Some(self.config.max_lifetime),
524        })
525    }
526
527    /// Get detailed connection pool metrics
528    ///
529    /// # Errors
530    ///
531    /// Returns an error if the metrics collection fails
532    #[instrument]
533    pub async fn get_pool_metrics(&self) -> ThingsResult<PoolMetrics> {
534        let pool_size = self.pool.size();
535        let idle_connections = self.pool.num_idle();
536        let active_connections = pool_size - u32::try_from(idle_connections).unwrap_or(0);
537
538        // Calculate utilization percentage
539        let max_connections = self.config.max_connections;
540        let utilization_percentage = if max_connections > 0 {
541            (f64::from(active_connections) / f64::from(max_connections)) * 100.0
542        } else {
543            0.0
544        };
545
546        // Test connection response time
547        let start_time = std::time::Instant::now();
548        let is_connected = self.is_connected().await;
549        let response_time_ms = u64::try_from(start_time.elapsed().as_millis()).unwrap_or(0);
550
551        Ok(PoolMetrics {
552            pool_size,
553            active_connections,
554            idle_connections: u32::try_from(idle_connections).unwrap_or(0),
555            max_connections,
556            min_connections: self.config.min_connections,
557            utilization_percentage,
558            is_healthy: is_connected,
559            response_time_ms,
560            connection_timeout: self.config.connect_timeout,
561            idle_timeout: Some(self.config.idle_timeout),
562            max_lifetime: Some(self.config.max_lifetime),
563        })
564    }
565
566    /// Perform a comprehensive health check including pool and database
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if the health check fails
571    #[instrument]
572    pub async fn comprehensive_health_check(&self) -> ThingsResult<ComprehensiveHealthStatus> {
573        let pool_health = self.get_pool_health().await?;
574        let pool_metrics = self.get_pool_metrics().await?;
575        let db_stats = self.get_stats().await?;
576
577        let overall_healthy = pool_health.is_healthy && pool_metrics.is_healthy;
578
579        Ok(ComprehensiveHealthStatus {
580            overall_healthy,
581            pool_health,
582            pool_metrics,
583            database_stats: db_stats,
584            timestamp: Utc::now(),
585        })
586    }
587
588    /// Get database statistics
589    ///
590    /// # Errors
591    ///
592    /// Returns an error if the database query fails
593    #[instrument]
594    pub async fn get_stats(&self) -> ThingsResult<DatabaseStats> {
595        let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask")
596            .fetch_one(&self.pool)
597            .await
598            .map_err(|e| ThingsError::unknown(format!("Failed to get task count: {e}")))?;
599
600        let project_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMTask WHERE type = 1")
601            .fetch_one(&self.pool)
602            .await
603            .map_err(|e| ThingsError::unknown(format!("Failed to get project count: {e}")))?;
604
605        let area_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM TMArea")
606            .fetch_one(&self.pool)
607            .await
608            .map_err(|e| ThingsError::unknown(format!("Failed to get area count: {e}")))?;
609
610        Ok(DatabaseStats {
611            task_count: task_count.try_into().unwrap_or(0),
612            project_count: project_count.try_into().unwrap_or(0),
613            area_count: area_count.try_into().unwrap_or(0),
614        })
615    }
616
617    /// Get all tasks from the database
618    ///
619    /// # Examples
620    ///
621    /// ```no_run
622    /// use things3_core::{ThingsDatabase, ThingsError};
623    /// use std::path::Path;
624    ///
625    /// # async fn example() -> Result<(), ThingsError> {
626    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
627    ///
628    /// // Get all tasks
629    /// let tasks = db.get_all_tasks().await?;
630    /// println!("Found {} total tasks", tasks.len());
631    ///
632    /// // Filter tasks by status
633    /// let incomplete: Vec<_> = tasks.iter()
634    ///     .filter(|t| t.status == things3_core::TaskStatus::Incomplete)
635    ///     .collect();
636    /// println!("Found {} incomplete tasks", incomplete.len());
637    /// # Ok(())
638    /// # }
639    /// ```
640    ///
641    /// # Errors
642    ///
643    /// Returns an error if the database query fails or if task data is invalid
644    #[instrument]
645    pub async fn get_all_tasks(&self) -> ThingsResult<Vec<Task>> {
646        let rows = sqlx::query(
647            r"
648            SELECT 
649                uuid, title, status, type, 
650                start_date, due_date, 
651                project_uuid, area_uuid, 
652                notes, tags, 
653                created, modified
654            FROM TMTask
655            ORDER BY created DESC
656            ",
657        )
658        .fetch_all(&self.pool)
659        .await
660        .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks: {e}")))?;
661
662        let mut tasks = Vec::new();
663        for row in rows {
664            let task = Task {
665                uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
666                    .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
667                title: row.get("title"),
668                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
669                task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
670                start_date: row
671                    .get::<Option<String>, _>("start_date")
672                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
673                deadline: row
674                    .get::<Option<String>, _>("due_date")
675                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
676                project_uuid: row
677                    .get::<Option<String>, _>("project_uuid")
678                    .and_then(|s| Uuid::parse_str(&s).ok()),
679                area_uuid: row
680                    .get::<Option<String>, _>("area_uuid")
681                    .and_then(|s| Uuid::parse_str(&s).ok()),
682                parent_uuid: None, // Not available in this query
683                notes: row.get("notes"),
684                tags: row
685                    .get::<Option<String>, _>("tags")
686                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
687                    .unwrap_or_default(),
688                children: Vec::new(), // Not available in this query
689                created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
690                    .ok()
691                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
692                modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
693                    .ok()
694                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
695                stop_date: None, // Not available in this query context
696            };
697            tasks.push(task);
698        }
699
700        debug!("Fetched {} tasks", tasks.len());
701        Ok(tasks)
702    }
703
704    /// Get all projects (from `TMTask` table where type = 1)
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the database query fails or if project data is invalid
709    #[instrument]
710    pub async fn get_all_projects(&self) -> ThingsResult<Vec<Project>> {
711        let rows = sqlx::query(
712            r"
713            SELECT 
714                uuid, title, status, 
715                area, notes, 
716                creationDate, userModificationDate,
717                startDate, deadline
718            FROM TMTask
719            WHERE type = 1 AND trashed = 0
720            ORDER BY creationDate DESC
721            ",
722        )
723        .fetch_all(&self.pool)
724        .await
725        .map_err(|e| ThingsError::unknown(format!("Failed to fetch projects: {e}")))?;
726
727        let mut projects = Vec::new();
728        for row in rows {
729            let project = Project {
730                uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
731                title: row.get("title"),
732                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
733                area_uuid: row
734                    .get::<Option<String>, _>("area")
735                    .map(|s| things_uuid_to_uuid(&s)),
736                notes: row.get("notes"),
737                deadline: row
738                    .get::<Option<i64>, _>("deadline")
739                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
740                    .map(|dt| dt.date_naive()),
741                start_date: row
742                    .get::<Option<i64>, _>("startDate")
743                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
744                    .map(|dt| dt.date_naive()),
745                tags: Vec::new(),  // TODO: Load tags separately
746                tasks: Vec::new(), // TODO: Load child tasks separately
747                created: {
748                    let ts_f64 = row.get::<f64, _>("creationDate");
749                    let ts = safe_timestamp_convert(ts_f64);
750                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
751                },
752                modified: {
753                    let ts_f64 = row.get::<f64, _>("userModificationDate");
754                    let ts = safe_timestamp_convert(ts_f64);
755                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
756                },
757            };
758            projects.push(project);
759        }
760
761        debug!("Fetched {} projects", projects.len());
762        Ok(projects)
763    }
764
765    /// Get all areas
766    ///
767    /// # Errors
768    ///
769    /// Returns an error if the database query fails or if area data is invalid
770    #[instrument]
771    pub async fn get_all_areas(&self) -> ThingsResult<Vec<Area>> {
772        // Get all areas, not just visible ones (MCP clients may want to see all)
773        let rows = sqlx::query(
774            r"
775            SELECT 
776                uuid, title, visible, `index`
777             FROM TMArea 
778            ORDER BY `index` ASC
779            ",
780        )
781        .fetch_all(&self.pool)
782        .await
783        .map_err(|e| ThingsError::unknown(format!("Failed to fetch areas: {e}")))?;
784
785        let mut areas = Vec::new();
786        for row in rows {
787            let uuid_str: String = row.get("uuid");
788            // Try standard UUID first, then fall back to Things UUID format
789            let uuid =
790                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
791
792            let area = Area {
793                uuid,
794                title: row.get("title"),
795                notes: None,          // Notes not stored in TMArea table
796                projects: Vec::new(), // TODO: Load projects separately
797                tags: Vec::new(),     // TODO: Load tags separately
798                created: Utc::now(),  // Creation date not available in TMArea
799                modified: Utc::now(), // Modification date not available in TMArea
800            };
801            areas.push(area);
802        }
803
804        debug!("Fetched {} areas", areas.len());
805        Ok(areas)
806    }
807
808    /// Get tasks by status
809    ///
810    /// # Errors
811    ///
812    /// Returns an error if the database query fails or if task data is invalid
813    #[instrument]
814    pub async fn get_tasks_by_status(&self, status: TaskStatus) -> ThingsResult<Vec<Task>> {
815        let status_value = status as i32;
816        let rows = sqlx::query(
817            r"
818            SELECT 
819                uuid, title, status, type, 
820                start_date, due_date, 
821                project_uuid, area_uuid, 
822                notes, tags, 
823                created, modified
824             FROM TMTask 
825            WHERE status = ?
826            ORDER BY created DESC
827            ",
828        )
829        .bind(status_value)
830        .fetch_all(&self.pool)
831        .await
832        .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks by status: {e}")))?;
833
834        let mut tasks = Vec::new();
835        for row in rows {
836            let task = Task {
837                uuid: Uuid::parse_str(&row.get::<String, _>("uuid"))
838                    .map_err(|e| ThingsError::unknown(format!("Invalid task UUID: {e}")))?,
839                title: row.get("title"),
840                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
841                task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
842                start_date: row
843                    .get::<Option<String>, _>("start_date")
844                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
845                deadline: row
846                    .get::<Option<String>, _>("due_date")
847                    .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
848                project_uuid: row
849                    .get::<Option<String>, _>("project_uuid")
850                    .and_then(|s| Uuid::parse_str(&s).ok()),
851                area_uuid: row
852                    .get::<Option<String>, _>("area_uuid")
853                    .and_then(|s| Uuid::parse_str(&s).ok()),
854                parent_uuid: None, // Not available in this query
855                notes: row.get("notes"),
856                tags: row
857                    .get::<Option<String>, _>("tags")
858                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
859                    .unwrap_or_default(),
860                children: Vec::new(), // Not available in this query
861                created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
862                    .ok()
863                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
864                modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
865                    .ok()
866                    .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
867                stop_date: None, // Not available in this query context
868            };
869            tasks.push(task);
870        }
871
872        debug!("Fetched {} tasks with status {:?}", tasks.len(), status);
873        Ok(tasks)
874    }
875
876    /// Search tasks by title or notes
877    ///
878    /// # Errors
879    ///
880    /// Returns an error if the database query fails or if task data is invalid
881    #[instrument]
882    pub async fn search_tasks(&self, query: &str) -> ThingsResult<Vec<Task>> {
883        let search_pattern = format!("%{query}%");
884        let rows = sqlx::query(
885            r"
886            SELECT 
887                uuid, title, status, type, 
888                startDate, deadline, stopDate,
889                project, area, heading,
890                notes, cachedTags, 
891                creationDate, userModificationDate
892            FROM TMTask
893            WHERE (title LIKE ? OR notes LIKE ?) AND trashed = 0 AND type = 0
894            ORDER BY creationDate DESC
895            ",
896        )
897        .bind(&search_pattern)
898        .bind(&search_pattern)
899        .fetch_all(&self.pool)
900        .await
901        .map_err(|e| ThingsError::unknown(format!("Failed to search tasks: {e}")))?;
902
903        let tasks = rows
904            .iter()
905            .map(map_task_row)
906            .collect::<ThingsResult<Vec<Task>>>()?;
907
908        debug!("Found {} tasks matching query: {}", tasks.len(), query);
909        Ok(tasks)
910    }
911
912    /// Search completed tasks in the logbook
913    ///
914    /// Returns completed tasks matching the provided filters.
915    /// All filters are optional and can be combined.
916    ///
917    /// # Parameters
918    ///
919    /// - `search_text`: Search in task titles and notes (case-insensitive)
920    /// - `from_date`: Start date for completion date range
921    /// - `to_date`: End date for completion date range
922    /// - `project_uuid`: Filter by project UUID
923    /// - `area_uuid`: Filter by area UUID
924    /// - `tags`: Filter by tags (all tags must match)
925    /// - `limit`: Maximum number of results (default: 50)
926    ///
927    /// # Errors
928    ///
929    /// Returns an error if the database query fails or if task data is invalid
930    #[allow(clippy::too_many_arguments)]
931    #[instrument(skip(self))]
932    pub async fn search_logbook(
933        &self,
934        search_text: Option<String>,
935        from_date: Option<NaiveDate>,
936        to_date: Option<NaiveDate>,
937        project_uuid: Option<Uuid>,
938        area_uuid: Option<Uuid>,
939        tags: Option<Vec<String>>,
940        limit: Option<u32>,
941    ) -> ThingsResult<Vec<Task>> {
942        // Apply limit
943        let result_limit = limit.unwrap_or(50).min(500);
944
945        // Build and execute query based on filters
946        let rows = if let Some(ref text) = search_text {
947            let pattern = format!("%{text}%");
948            let mut q = String::from(
949                "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, cachedTags, creationDate, userModificationDate FROM TMTask WHERE status = 1 AND trashed = 0 AND type = 0",
950            );
951            q.push_str(" AND (title LIKE ? OR notes LIKE ?)");
952
953            if let Some(date) = from_date {
954                // stopDate is stored as Unix timestamp (seconds since 1970-01-01)
955                let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
956                let timestamp = date_time.timestamp() as f64;
957                q.push_str(&format!(" AND stopDate >= {}", timestamp));
958            }
959
960            if let Some(date) = to_date {
961                // Include tasks completed on to_date by adding 1 day
962                let end_date = date + chrono::Duration::days(1);
963                let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
964                let timestamp = date_time.timestamp() as f64;
965                q.push_str(&format!(" AND stopDate < {}", timestamp));
966            }
967
968            if let Some(uuid) = project_uuid {
969                q.push_str(&format!(" AND project = '{}'", uuid));
970            }
971
972            if let Some(uuid) = area_uuid {
973                q.push_str(&format!(" AND area = '{}'", uuid));
974            }
975
976            q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
977
978            sqlx::query(&q)
979                .bind(&pattern)
980                .bind(&pattern)
981                .fetch_all(&self.pool)
982                .await
983                .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
984        } else {
985            let mut q = String::from(
986                "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, cachedTags, creationDate, userModificationDate FROM TMTask WHERE status = 1 AND trashed = 0 AND type = 0",
987            );
988
989            if let Some(date) = from_date {
990                // stopDate is stored as Unix timestamp (seconds since 1970-01-01)
991                let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
992                let timestamp = date_time.timestamp() as f64;
993                q.push_str(&format!(" AND stopDate >= {}", timestamp));
994            }
995
996            if let Some(date) = to_date {
997                // Include tasks completed on to_date by adding 1 day
998                let end_date = date + chrono::Duration::days(1);
999                let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1000                let timestamp = date_time.timestamp() as f64;
1001                q.push_str(&format!(" AND stopDate < {}", timestamp));
1002            }
1003
1004            if let Some(uuid) = project_uuid {
1005                q.push_str(&format!(" AND project = '{}'", uuid));
1006            }
1007
1008            if let Some(uuid) = area_uuid {
1009                q.push_str(&format!(" AND area = '{}'", uuid));
1010            }
1011
1012            q.push_str(&format!(" ORDER BY stopDate DESC LIMIT {result_limit}"));
1013
1014            sqlx::query(&q)
1015                .fetch_all(&self.pool)
1016                .await
1017                .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
1018        };
1019
1020        // Filter by tags if provided
1021        let mut tasks = rows
1022            .iter()
1023            .map(map_task_row)
1024            .collect::<ThingsResult<Vec<Task>>>()?;
1025
1026        if let Some(ref filter_tags) = tags {
1027            if !filter_tags.is_empty() {
1028                tasks.retain(|task| {
1029                    // Check if task has all required tags
1030                    filter_tags
1031                        .iter()
1032                        .all(|filter_tag| task.tags.contains(filter_tag))
1033                });
1034            }
1035        }
1036
1037        debug!("Found {} completed tasks in logbook", tasks.len());
1038        Ok(tasks)
1039    }
1040
1041    /// Get inbox tasks (incomplete tasks without project)
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns an error if the database query fails or if task data is invalid
1046    #[instrument(skip(self))]
1047    pub async fn get_inbox(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1048        let query = if let Some(limit) = limit {
1049            format!("SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE type = 0 AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC LIMIT {limit}")
1050        } else {
1051            "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE type = 0 AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC"
1052                .to_string()
1053        };
1054
1055        let rows = sqlx::query(&query)
1056            .fetch_all(&self.pool)
1057            .await
1058            .map_err(|e| ThingsError::unknown(format!("Failed to fetch inbox tasks: {e}")))?;
1059
1060        let tasks = rows
1061            .iter()
1062            .map(map_task_row)
1063            .collect::<ThingsResult<Vec<Task>>>()?;
1064
1065        Ok(tasks)
1066    }
1067
1068    /// Get today's tasks (incomplete tasks due today or started today)
1069    ///
1070    /// # Errors
1071    ///
1072    /// Returns an error if the database query fails or if task data is invalid
1073    ///
1074    /// # Panics
1075    ///
1076    /// Panics if the current date cannot be converted to a valid time with hours, minutes, and seconds
1077    #[instrument(skip(self))]
1078    pub async fn get_today(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
1079        // Things 3 uses the `todayIndex` column to mark tasks that appear in "Today"
1080        // A task is in "Today" if todayIndex IS NOT NULL AND todayIndex != 0
1081        let query = if let Some(limit) = limit {
1082            format!(
1083                "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC LIMIT {limit}"
1084            )
1085        } else {
1086            "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, cachedTags FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC".to_string()
1087        };
1088
1089        let rows = sqlx::query(&query)
1090            .fetch_all(&self.pool)
1091            .await
1092            .map_err(|e| ThingsError::unknown(format!("Failed to fetch today's tasks: {e}")))?;
1093
1094        let tasks = rows
1095            .iter()
1096            .map(map_task_row)
1097            .collect::<ThingsResult<Vec<Task>>>()?;
1098
1099        Ok(tasks)
1100    }
1101
1102    /// Get all projects (alias for `get_all_projects` for compatibility)
1103    ///
1104    /// # Errors
1105    ///
1106    /// Returns an error if the database query fails or if project data is invalid
1107    #[instrument(skip(self))]
1108    pub async fn get_projects(&self, limit: Option<usize>) -> ThingsResult<Vec<Project>> {
1109        let _ = limit; // Currently unused but kept for API compatibility
1110        self.get_all_projects().await
1111    }
1112
1113    /// Get all areas (alias for `get_all_areas` for compatibility)
1114    ///
1115    /// # Errors
1116    ///
1117    /// Returns an error if the database query fails or if area data is invalid
1118    #[instrument(skip(self))]
1119    pub async fn get_areas(&self) -> ThingsResult<Vec<Area>> {
1120        self.get_all_areas().await
1121    }
1122
1123    /// Create a new task in the database
1124    ///
1125    /// Validates:
1126    /// - Project UUID exists if provided
1127    /// - Area UUID exists if provided
1128    /// - Parent task UUID exists if provided
1129    /// - Date range (deadline >= start_date)
1130    ///
1131    /// Returns the UUID of the created task
1132    ///
1133    /// # Examples
1134    ///
1135    /// ```no_run
1136    /// use things3_core::{ThingsDatabase, CreateTaskRequest, ThingsError};
1137    /// use std::path::Path;
1138    /// use chrono::NaiveDate;
1139    ///
1140    /// # async fn example() -> Result<(), ThingsError> {
1141    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
1142    ///
1143    /// // Create a simple task
1144    /// let request = CreateTaskRequest {
1145    ///     title: "Buy groceries".to_string(),
1146    ///     notes: Some("Milk, eggs, bread".to_string()),
1147    ///     deadline: Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()),
1148    ///     start_date: None,
1149    ///     project_uuid: None,
1150    ///     area_uuid: None,
1151    ///     parent_uuid: None,
1152    ///     tags: None,
1153    ///     task_type: None,
1154    ///     status: None,
1155    /// };
1156    ///
1157    /// let task_uuid = db.create_task(request).await?;
1158    /// println!("Created task with UUID: {}", task_uuid);
1159    /// # Ok(())
1160    /// # }
1161    /// ```
1162    ///
1163    /// # Errors
1164    ///
1165    /// Returns an error if validation fails or if the database insert fails
1166    #[instrument(skip(self))]
1167    pub async fn create_task(&self, request: CreateTaskRequest) -> ThingsResult<Uuid> {
1168        // Validate date range (deadline must be >= start_date)
1169        crate::database::validate_date_range(request.start_date, request.deadline)?;
1170
1171        // Generate UUID for new task
1172        let uuid = Uuid::new_v4();
1173        let uuid_str = uuid.to_string();
1174
1175        // Validate referenced entities
1176        if let Some(project_uuid) = &request.project_uuid {
1177            validators::validate_project_exists(&self.pool, project_uuid).await?;
1178        }
1179
1180        if let Some(area_uuid) = &request.area_uuid {
1181            validators::validate_area_exists(&self.pool, area_uuid).await?;
1182        }
1183
1184        if let Some(parent_uuid) = &request.parent_uuid {
1185            validators::validate_task_exists(&self.pool, parent_uuid).await?;
1186        }
1187
1188        // Convert dates to Things 3 format (seconds since 2001-01-01)
1189        let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1190        let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1191
1192        // Get current timestamp for creation/modification dates
1193        let now = Utc::now().timestamp() as f64;
1194
1195        // Serialize tags to binary format (if provided)
1196        let cached_tags = request
1197            .tags
1198            .as_ref()
1199            .map(|tags| serialize_tags_to_blob(tags))
1200            .transpose()?;
1201
1202        // Insert into TMTask table
1203        sqlx::query(
1204            r"
1205            INSERT INTO TMTask (
1206                uuid, title, type, status, notes,
1207                startDate, deadline, project, area, heading,
1208                cachedTags, creationDate, userModificationDate,
1209                trashed
1210            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1211            ",
1212        )
1213        .bind(&uuid_str)
1214        .bind(&request.title)
1215        .bind(request.task_type.unwrap_or(TaskType::Todo) as i32)
1216        .bind(request.status.unwrap_or(TaskStatus::Incomplete) as i32)
1217        .bind(request.notes.as_ref())
1218        .bind(start_date_ts)
1219        .bind(deadline_ts)
1220        .bind(request.project_uuid.map(|u| u.to_string()))
1221        .bind(request.area_uuid.map(|u| u.to_string()))
1222        .bind(request.parent_uuid.map(|u| u.to_string()))
1223        .bind(cached_tags)
1224        .bind(now)
1225        .bind(now)
1226        .bind(0) // not trashed
1227        .execute(&self.pool)
1228        .await
1229        .map_err(|e| ThingsError::unknown(format!("Failed to create task: {e}")))?;
1230
1231        info!("Created task with UUID: {}", uuid);
1232        Ok(uuid)
1233    }
1234
1235    /// Create a new project
1236    ///
1237    /// Projects are tasks with type = 1 in the TMTask table
1238    ///
1239    /// # Errors
1240    ///
1241    /// Returns an error if validation fails or the database insert fails
1242    #[instrument(skip(self))]
1243    pub async fn create_project(
1244        &self,
1245        request: crate::models::CreateProjectRequest,
1246    ) -> ThingsResult<Uuid> {
1247        // Validate date range (deadline must be >= start_date)
1248        crate::database::validate_date_range(request.start_date, request.deadline)?;
1249
1250        // Generate UUID for new project
1251        let uuid = Uuid::new_v4();
1252        let uuid_str = uuid.to_string();
1253
1254        // Validate area if provided
1255        if let Some(area_uuid) = &request.area_uuid {
1256            validators::validate_area_exists(&self.pool, area_uuid).await?;
1257        }
1258
1259        // Convert dates to Things 3 format (seconds since 2001-01-01)
1260        let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
1261        let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
1262
1263        // Get current timestamp for creation/modification dates
1264        let now = Utc::now().timestamp() as f64;
1265
1266        // Serialize tags to binary format (if provided)
1267        let cached_tags = request
1268            .tags
1269            .as_ref()
1270            .map(|tags| serialize_tags_to_blob(tags))
1271            .transpose()?;
1272
1273        // Insert into TMTask table with type = 1 (project)
1274        sqlx::query(
1275            r"
1276            INSERT INTO TMTask (
1277                uuid, title, type, status, notes,
1278                startDate, deadline, project, area, heading,
1279                cachedTags, creationDate, userModificationDate,
1280                trashed
1281            ) VALUES (?, ?, 1, 0, ?, ?, ?, NULL, ?, NULL, ?, ?, ?, 0)
1282            ",
1283        )
1284        .bind(&uuid_str)
1285        .bind(&request.title)
1286        .bind(request.notes.as_ref())
1287        .bind(start_date_ts)
1288        .bind(deadline_ts)
1289        .bind(request.area_uuid.map(|u| u.to_string()))
1290        .bind(cached_tags)
1291        .bind(now)
1292        .bind(now)
1293        .execute(&self.pool)
1294        .await
1295        .map_err(|e| ThingsError::unknown(format!("Failed to create project: {e}")))?;
1296
1297        info!("Created project with UUID: {}", uuid);
1298        Ok(uuid)
1299    }
1300
1301    /// Update an existing task
1302    ///
1303    /// Only updates fields that are provided (Some(_))
1304    /// Validates existence of referenced entities
1305    ///
1306    /// # Errors
1307    ///
1308    /// Returns an error if the task doesn't exist, validation fails, or the database update fails
1309    #[instrument(skip(self))]
1310    pub async fn update_task(&self, request: UpdateTaskRequest) -> ThingsResult<()> {
1311        // Verify task exists
1312        validators::validate_task_exists(&self.pool, &request.uuid).await?;
1313
1314        // Validate dates if either is being updated
1315        if request.start_date.is_some() || request.deadline.is_some() {
1316            // Get current task to merge dates
1317            if let Some(current_task) = self.get_task_by_uuid(&request.uuid).await? {
1318                let final_start = request.start_date.or(current_task.start_date);
1319                let final_deadline = request.deadline.or(current_task.deadline);
1320                crate::database::validate_date_range(final_start, final_deadline)?;
1321            }
1322        }
1323
1324        // Validate referenced entities if being updated
1325        if let Some(project_uuid) = &request.project_uuid {
1326            validators::validate_project_exists(&self.pool, project_uuid).await?;
1327        }
1328
1329        if let Some(area_uuid) = &request.area_uuid {
1330            validators::validate_area_exists(&self.pool, area_uuid).await?;
1331        }
1332
1333        // Use the TaskUpdateBuilder to construct the query
1334        let builder = TaskUpdateBuilder::from_request(&request);
1335
1336        // If no fields to update, just return (modification date will still be updated)
1337        if builder.is_empty() {
1338            return Ok(());
1339        }
1340
1341        let query_string = builder.build_query_string();
1342        let mut q = sqlx::query(&query_string);
1343
1344        // Bind values in the same order as the builder added fields
1345        if let Some(title) = &request.title {
1346            q = q.bind(title);
1347        }
1348
1349        if let Some(notes) = &request.notes {
1350            q = q.bind(notes);
1351        }
1352
1353        if let Some(start_date) = request.start_date {
1354            q = q.bind(naive_date_to_things_timestamp(start_date));
1355        }
1356
1357        if let Some(deadline) = request.deadline {
1358            q = q.bind(naive_date_to_things_timestamp(deadline));
1359        }
1360
1361        if let Some(status) = request.status {
1362            q = q.bind(status as i32);
1363        }
1364
1365        if let Some(project_uuid) = request.project_uuid {
1366            q = q.bind(project_uuid.to_string());
1367        }
1368
1369        if let Some(area_uuid) = request.area_uuid {
1370            q = q.bind(area_uuid.to_string());
1371        }
1372
1373        if let Some(tags) = &request.tags {
1374            let cached_tags = serialize_tags_to_blob(tags)?;
1375            q = q.bind(cached_tags);
1376        }
1377
1378        // Bind modification date and UUID (always added by builder)
1379        let now = Utc::now().timestamp() as f64;
1380        q = q.bind(now).bind(request.uuid.to_string());
1381
1382        q.execute(&self.pool)
1383            .await
1384            .map_err(|e| ThingsError::unknown(format!("Failed to update task: {e}")))?;
1385
1386        info!("Updated task with UUID: {}", request.uuid);
1387        Ok(())
1388    }
1389
1390    /// Get a single project by UUID
1391    ///
1392    /// Returns `None` if the project doesn't exist or is trashed
1393    ///
1394    /// # Errors
1395    ///
1396    /// Returns an error if the database query fails
1397    #[instrument(skip(self))]
1398    pub async fn get_project_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Project>> {
1399        let row = sqlx::query(
1400            r"
1401            SELECT 
1402                uuid, title, status, 
1403                area, notes, 
1404                creationDate, userModificationDate,
1405                startDate, deadline,
1406                trashed, type
1407            FROM TMTask
1408            WHERE uuid = ? AND type = 1
1409            ",
1410        )
1411        .bind(uuid.to_string())
1412        .fetch_optional(&self.pool)
1413        .await
1414        .map_err(|e| ThingsError::unknown(format!("Failed to fetch project: {e}")))?;
1415
1416        if let Some(row) = row {
1417            // Check if trashed
1418            let trashed: i64 = row.get("trashed");
1419            if trashed == 1 {
1420                return Ok(None); // Return None for trashed projects
1421            }
1422
1423            let project = Project {
1424                uuid: things_uuid_to_uuid(&row.get::<String, _>("uuid")),
1425                title: row.get("title"),
1426                status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
1427                area_uuid: row
1428                    .get::<Option<String>, _>("area")
1429                    .map(|s| things_uuid_to_uuid(&s)),
1430                notes: row.get("notes"),
1431                deadline: row
1432                    .get::<Option<i64>, _>("deadline")
1433                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
1434                    .map(|dt| dt.date_naive()),
1435                start_date: row
1436                    .get::<Option<i64>, _>("startDate")
1437                    .and_then(|ts| DateTime::from_timestamp(ts, 0))
1438                    .map(|dt| dt.date_naive()),
1439                tags: Vec::new(),  // TODO: Load tags separately
1440                tasks: Vec::new(), // TODO: Load child tasks separately
1441                created: {
1442                    let ts_f64 = row.get::<f64, _>("creationDate");
1443                    let ts = safe_timestamp_convert(ts_f64);
1444                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1445                },
1446                modified: {
1447                    let ts_f64 = row.get::<f64, _>("userModificationDate");
1448                    let ts = safe_timestamp_convert(ts_f64);
1449                    DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
1450                },
1451            };
1452            Ok(Some(project))
1453        } else {
1454            Ok(None)
1455        }
1456    }
1457
1458    /// Update an existing project
1459    ///
1460    /// Only updates fields that are provided (Some(_))
1461    /// Validates existence and that the entity is a project (type = 1)
1462    ///
1463    /// # Errors
1464    ///
1465    /// Returns an error if the project doesn't exist, validation fails, or the database update fails
1466    #[instrument(skip(self))]
1467    pub async fn update_project(
1468        &self,
1469        request: crate::models::UpdateProjectRequest,
1470    ) -> ThingsResult<()> {
1471        // Verify project exists (type = 1, trashed = 0)
1472        validators::validate_project_exists(&self.pool, &request.uuid).await?;
1473
1474        // Validate dates if either is being updated
1475        if request.start_date.is_some() || request.deadline.is_some() {
1476            // Fetch current project to merge dates
1477            if let Some(current_project) = self.get_project_by_uuid(&request.uuid).await? {
1478                let final_start = request.start_date.or(current_project.start_date);
1479                let final_deadline = request.deadline.or(current_project.deadline);
1480                crate::database::validate_date_range(final_start, final_deadline)?;
1481            }
1482        }
1483
1484        // Validate area if being updated
1485        if let Some(area_uuid) = &request.area_uuid {
1486            validators::validate_area_exists(&self.pool, area_uuid).await?;
1487        }
1488
1489        // Build dynamic query using TaskUpdateBuilder
1490        let mut builder = TaskUpdateBuilder::new();
1491
1492        // Add fields to update
1493        if request.title.is_some() {
1494            builder = builder.add_field("title");
1495        }
1496        if request.notes.is_some() {
1497            builder = builder.add_field("notes");
1498        }
1499        if request.start_date.is_some() {
1500            builder = builder.add_field("startDate");
1501        }
1502        if request.deadline.is_some() {
1503            builder = builder.add_field("deadline");
1504        }
1505        if request.area_uuid.is_some() {
1506            builder = builder.add_field("area");
1507        }
1508        if request.tags.is_some() {
1509            builder = builder.add_field("cachedTags");
1510        }
1511
1512        // If nothing to update, return early
1513        if builder.is_empty() {
1514            return Ok(());
1515        }
1516
1517        // Build query string
1518        let query_str = builder.build_query_string();
1519        let mut q = sqlx::query(&query_str);
1520
1521        // Bind values in the same order they were added to the builder
1522        if let Some(ref title) = request.title {
1523            q = q.bind(title);
1524        }
1525        if let Some(ref notes) = request.notes {
1526            q = q.bind(notes);
1527        }
1528        if let Some(start_date) = request.start_date {
1529            q = q.bind(naive_date_to_things_timestamp(start_date));
1530        }
1531        if let Some(deadline) = request.deadline {
1532            q = q.bind(naive_date_to_things_timestamp(deadline));
1533        }
1534        if let Some(area_uuid) = request.area_uuid {
1535            q = q.bind(area_uuid.to_string());
1536        }
1537        if let Some(tags) = &request.tags {
1538            let cached_tags = serialize_tags_to_blob(tags)?;
1539            q = q.bind(cached_tags);
1540        }
1541
1542        // Bind modification date and UUID (always added by builder)
1543        let now = Utc::now().timestamp() as f64;
1544        q = q.bind(now).bind(request.uuid.to_string());
1545
1546        q.execute(&self.pool)
1547            .await
1548            .map_err(|e| ThingsError::unknown(format!("Failed to update project: {e}")))?;
1549
1550        info!("Updated project with UUID: {}", request.uuid);
1551        Ok(())
1552    }
1553
1554    /// Get a task by its UUID
1555    ///
1556    /// # Errors
1557    ///
1558    /// Returns an error if the task does not exist or if the database query fails
1559    #[instrument(skip(self))]
1560    pub async fn get_task_by_uuid(&self, uuid: &Uuid) -> ThingsResult<Option<Task>> {
1561        let row = sqlx::query(
1562            r"
1563            SELECT 
1564                uuid, title, status, type, 
1565                startDate, deadline, stopDate,
1566                project, area, heading,
1567                notes, cachedTags, 
1568                creationDate, userModificationDate,
1569                trashed
1570            FROM TMTask
1571            WHERE uuid = ?
1572            ",
1573        )
1574        .bind(uuid.to_string())
1575        .fetch_optional(&self.pool)
1576        .await
1577        .map_err(|e| ThingsError::unknown(format!("Failed to fetch task: {e}")))?;
1578
1579        if let Some(row) = row {
1580            // Check if trashed
1581            let trashed: i64 = row.get("trashed");
1582            if trashed == 1 {
1583                return Ok(None); // Return None for trashed tasks
1584            }
1585
1586            // Use the centralized mapper
1587            let task = map_task_row(&row)?;
1588            Ok(Some(task))
1589        } else {
1590            Ok(None)
1591        }
1592    }
1593
1594    /// Mark a task as completed
1595    ///
1596    /// # Errors
1597    ///
1598    /// Returns an error if the task does not exist or if the database update fails
1599    #[instrument(skip(self))]
1600    pub async fn complete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1601        // Verify task exists
1602        validators::validate_task_exists(&self.pool, uuid).await?;
1603
1604        let now = Utc::now().timestamp() as f64;
1605
1606        sqlx::query(
1607            "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1608        )
1609        .bind(now)
1610        .bind(now)
1611        .bind(uuid.to_string())
1612        .execute(&self.pool)
1613        .await
1614        .map_err(|e| ThingsError::unknown(format!("Failed to complete task: {e}")))?;
1615
1616        info!("Completed task with UUID: {}", uuid);
1617        Ok(())
1618    }
1619
1620    /// Mark a completed task as incomplete
1621    ///
1622    /// # Errors
1623    ///
1624    /// Returns an error if the task does not exist or if the database update fails
1625    #[instrument(skip(self))]
1626    pub async fn uncomplete_task(&self, uuid: &Uuid) -> ThingsResult<()> {
1627        // Verify task exists
1628        validators::validate_task_exists(&self.pool, uuid).await?;
1629
1630        let now = Utc::now().timestamp() as f64;
1631
1632        sqlx::query(
1633            "UPDATE TMTask SET status = 0, stopDate = NULL, userModificationDate = ? WHERE uuid = ?",
1634        )
1635        .bind(now)
1636        .bind(uuid.to_string())
1637        .execute(&self.pool)
1638        .await
1639        .map_err(|e| ThingsError::unknown(format!("Failed to uncomplete task: {e}")))?;
1640
1641        info!("Uncompleted task with UUID: {}", uuid);
1642        Ok(())
1643    }
1644
1645    /// Complete a project and optionally handle its child tasks
1646    ///
1647    /// # Errors
1648    ///
1649    /// Returns an error if the project doesn't exist or if the database update fails
1650    #[instrument(skip(self))]
1651    pub async fn complete_project(
1652        &self,
1653        uuid: &Uuid,
1654        child_handling: crate::models::ProjectChildHandling,
1655    ) -> ThingsResult<()> {
1656        // Verify project exists
1657        validators::validate_project_exists(&self.pool, uuid).await?;
1658
1659        let now = Utc::now().timestamp() as f64;
1660
1661        // Handle child tasks based on the handling mode
1662        match child_handling {
1663            crate::models::ProjectChildHandling::Error => {
1664                // Check if project has children
1665                let child_count: i64 = sqlx::query_scalar(
1666                    "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1667                )
1668                .bind(uuid.to_string())
1669                .fetch_one(&self.pool)
1670                .await
1671                .map_err(|e| {
1672                    ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1673                })?;
1674
1675                if child_count > 0 {
1676                    return Err(ThingsError::unknown(format!(
1677                        "Project {} has {} child task(s). Use cascade or orphan mode to complete.",
1678                        uuid, child_count
1679                    )));
1680                }
1681            }
1682            crate::models::ProjectChildHandling::Cascade => {
1683                // Complete all child tasks
1684                sqlx::query(
1685                    "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE project = ? AND trashed = 0",
1686                )
1687                .bind(now)
1688                .bind(now)
1689                .bind(uuid.to_string())
1690                .execute(&self.pool)
1691                .await
1692                .map_err(|e| ThingsError::unknown(format!("Failed to complete child tasks: {e}")))?;
1693            }
1694            crate::models::ProjectChildHandling::Orphan => {
1695                // Move child tasks to inbox (set project to NULL)
1696                sqlx::query(
1697                    "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
1698                )
1699                .bind(now)
1700                .bind(uuid.to_string())
1701                .execute(&self.pool)
1702                .await
1703                .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
1704            }
1705        }
1706
1707        // Complete the project
1708        sqlx::query(
1709            "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
1710        )
1711        .bind(now)
1712        .bind(now)
1713        .bind(uuid.to_string())
1714        .execute(&self.pool)
1715        .await
1716        .map_err(|e| ThingsError::unknown(format!("Failed to complete project: {e}")))?;
1717
1718        info!("Completed project with UUID: {}", uuid);
1719        Ok(())
1720    }
1721
1722    /// Soft delete a task (set trashed flag)
1723    ///
1724    /// # Errors
1725    ///
1726    /// Returns an error if the task does not exist, if child handling fails, or if the database update fails
1727    #[instrument(skip(self))]
1728    pub async fn delete_task(
1729        &self,
1730        uuid: &Uuid,
1731        child_handling: DeleteChildHandling,
1732    ) -> ThingsResult<()> {
1733        // Verify task exists
1734        validators::validate_task_exists(&self.pool, uuid).await?;
1735
1736        // Check for child tasks
1737        let children = sqlx::query("SELECT uuid FROM TMTask WHERE heading = ? AND trashed = 0")
1738            .bind(uuid.to_string())
1739            .fetch_all(&self.pool)
1740            .await
1741            .map_err(|e| ThingsError::unknown(format!("Failed to query child tasks: {e}")))?;
1742
1743        let has_children = !children.is_empty();
1744
1745        if has_children {
1746            match child_handling {
1747                DeleteChildHandling::Error => {
1748                    return Err(ThingsError::unknown(format!(
1749                        "Task {} has {} child task(s). Use cascade or orphan mode to delete.",
1750                        uuid,
1751                        children.len()
1752                    )));
1753                }
1754                DeleteChildHandling::Cascade => {
1755                    // Delete all children
1756                    let now = Utc::now().timestamp() as f64;
1757                    for child_row in &children {
1758                        let child_uuid: String = child_row.get("uuid");
1759                        sqlx::query(
1760                            "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?",
1761                        )
1762                        .bind(now)
1763                        .bind(&child_uuid)
1764                        .execute(&self.pool)
1765                        .await
1766                        .map_err(|e| {
1767                            ThingsError::unknown(format!("Failed to delete child task: {e}"))
1768                        })?;
1769                    }
1770                    info!("Cascade deleted {} child task(s)", children.len());
1771                }
1772                DeleteChildHandling::Orphan => {
1773                    // Clear parent reference for children
1774                    let now = Utc::now().timestamp() as f64;
1775                    for child_row in &children {
1776                        let child_uuid: String = child_row.get("uuid");
1777                        sqlx::query(
1778                            "UPDATE TMTask SET heading = NULL, userModificationDate = ? WHERE uuid = ?",
1779                        )
1780                        .bind(now)
1781                        .bind(&child_uuid)
1782                        .execute(&self.pool)
1783                        .await
1784                        .map_err(|e| {
1785                            ThingsError::unknown(format!("Failed to orphan child task: {e}"))
1786                        })?;
1787                    }
1788                    info!("Orphaned {} child task(s)", children.len());
1789                }
1790            }
1791        }
1792
1793        // Delete the parent task
1794        let now = Utc::now().timestamp() as f64;
1795        sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
1796            .bind(now)
1797            .bind(uuid.to_string())
1798            .execute(&self.pool)
1799            .await
1800            .map_err(|e| ThingsError::unknown(format!("Failed to delete task: {e}")))?;
1801
1802        info!("Deleted task with UUID: {}", uuid);
1803        Ok(())
1804    }
1805
1806    /// Soft delete a project and handle its child tasks
1807    ///
1808    /// # Errors
1809    ///
1810    /// Returns an error if the project doesn't exist, if child handling fails, or if the database update fails
1811    #[instrument(skip(self))]
1812    pub async fn delete_project(
1813        &self,
1814        uuid: &Uuid,
1815        child_handling: crate::models::ProjectChildHandling,
1816    ) -> ThingsResult<()> {
1817        // Verify project exists
1818        validators::validate_project_exists(&self.pool, uuid).await?;
1819
1820        let now = Utc::now().timestamp() as f64;
1821
1822        // Handle child tasks based on the handling mode
1823        match child_handling {
1824            crate::models::ProjectChildHandling::Error => {
1825                // Check if project has children
1826                let child_count: i64 = sqlx::query_scalar(
1827                    "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
1828                )
1829                .bind(uuid.to_string())
1830                .fetch_one(&self.pool)
1831                .await
1832                .map_err(|e| {
1833                    ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
1834                })?;
1835
1836                if child_count > 0 {
1837                    return Err(ThingsError::unknown(format!(
1838                        "Project {} has {} child task(s). Use cascade or orphan mode to delete.",
1839                        uuid, child_count
1840                    )));
1841                }
1842            }
1843            crate::models::ProjectChildHandling::Cascade => {
1844                // Delete all child tasks
1845                sqlx::query(
1846                    "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE project = ? AND trashed = 0",
1847                )
1848                .bind(now)
1849                .bind(uuid.to_string())
1850                .execute(&self.pool)
1851                .await
1852                .map_err(|e| ThingsError::unknown(format!("Failed to delete child tasks: {e}")))?;
1853            }
1854            crate::models::ProjectChildHandling::Orphan => {
1855                // Move child tasks to inbox (set project to NULL)
1856                sqlx::query(
1857                    "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
1858                )
1859                .bind(now)
1860                .bind(uuid.to_string())
1861                .execute(&self.pool)
1862                .await
1863                .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
1864            }
1865        }
1866
1867        // Delete the project
1868        sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
1869            .bind(now)
1870            .bind(uuid.to_string())
1871            .execute(&self.pool)
1872            .await
1873            .map_err(|e| ThingsError::unknown(format!("Failed to delete project: {e}")))?;
1874
1875        info!("Deleted project with UUID: {}", uuid);
1876        Ok(())
1877    }
1878
1879    /// Create a new area
1880    ///
1881    /// # Errors
1882    ///
1883    /// Returns an error if the database insert fails
1884    #[instrument(skip(self))]
1885    pub async fn create_area(
1886        &self,
1887        request: crate::models::CreateAreaRequest,
1888    ) -> ThingsResult<Uuid> {
1889        // Generate UUID for new area
1890        let uuid = Uuid::new_v4();
1891        let uuid_str = uuid.to_string();
1892
1893        // Get current timestamp for creation/modification dates
1894        let now = Utc::now().timestamp() as f64;
1895
1896        // Calculate next index (max + 1)
1897        let max_index: Option<i64> = sqlx::query_scalar("SELECT MAX(`index`) FROM TMArea")
1898            .fetch_one(&self.pool)
1899            .await
1900            .map_err(|e| ThingsError::unknown(format!("Failed to get max area index: {e}")))?;
1901
1902        let next_index = max_index.unwrap_or(-1) + 1;
1903
1904        // Insert into TMArea table
1905        sqlx::query(
1906            r"
1907            INSERT INTO TMArea (
1908                uuid, title, visible, `index`,
1909                creationDate, userModificationDate
1910            ) VALUES (?, ?, 1, ?, ?, ?)
1911            ",
1912        )
1913        .bind(&uuid_str)
1914        .bind(&request.title)
1915        .bind(next_index)
1916        .bind(now)
1917        .bind(now)
1918        .execute(&self.pool)
1919        .await
1920        .map_err(|e| ThingsError::unknown(format!("Failed to create area: {e}")))?;
1921
1922        info!("Created area with UUID: {}", uuid);
1923        Ok(uuid)
1924    }
1925
1926    /// Update an existing area
1927    ///
1928    /// # Errors
1929    ///
1930    /// Returns an error if the area doesn't exist or if the database update fails
1931    #[instrument(skip(self))]
1932    pub async fn update_area(&self, request: crate::models::UpdateAreaRequest) -> ThingsResult<()> {
1933        // Verify area exists
1934        validators::validate_area_exists(&self.pool, &request.uuid).await?;
1935
1936        let now = Utc::now().timestamp() as f64;
1937
1938        sqlx::query("UPDATE TMArea SET title = ?, userModificationDate = ? WHERE uuid = ?")
1939            .bind(&request.title)
1940            .bind(now)
1941            .bind(request.uuid.to_string())
1942            .execute(&self.pool)
1943            .await
1944            .map_err(|e| ThingsError::unknown(format!("Failed to update area: {e}")))?;
1945
1946        info!("Updated area with UUID: {}", request.uuid);
1947        Ok(())
1948    }
1949
1950    /// Delete an area
1951    ///
1952    /// Hard delete (areas don't have a trashed field)
1953    /// Orphans all projects in the area by setting their area to NULL
1954    ///
1955    /// # Errors
1956    ///
1957    /// Returns an error if the area doesn't exist or if the database delete fails
1958    #[instrument(skip(self))]
1959    pub async fn delete_area(&self, uuid: &Uuid) -> ThingsResult<()> {
1960        // Verify area exists
1961        validators::validate_area_exists(&self.pool, uuid).await?;
1962
1963        let now = Utc::now().timestamp() as f64;
1964
1965        // Orphan all projects in this area (set area to NULL)
1966        sqlx::query(
1967            "UPDATE TMTask SET area = NULL, userModificationDate = ? WHERE area = ? AND type = 1 AND trashed = 0",
1968        )
1969        .bind(now)
1970        .bind(uuid.to_string())
1971        .execute(&self.pool)
1972        .await
1973        .map_err(|e| ThingsError::unknown(format!("Failed to orphan projects in area: {e}")))?;
1974
1975        // Delete the area (hard delete)
1976        sqlx::query("DELETE FROM TMArea WHERE uuid = ?")
1977            .bind(uuid.to_string())
1978            .execute(&self.pool)
1979            .await
1980            .map_err(|e| ThingsError::unknown(format!("Failed to delete area: {e}")))?;
1981
1982        info!("Deleted area with UUID: {}", uuid);
1983        Ok(())
1984    }
1985
1986    // ========================================================================
1987    // TAG OPERATIONS (with smart duplicate prevention)
1988    // ========================================================================
1989
1990    /// Find a tag by normalized title (exact match, case-insensitive)
1991    ///
1992    /// # Errors
1993    ///
1994    /// Returns an error if the database query fails
1995    #[instrument(skip(self))]
1996    pub async fn find_tag_by_normalized_title(
1997        &self,
1998        normalized: &str,
1999    ) -> ThingsResult<Option<crate::models::Tag>> {
2000        let row = sqlx::query(
2001            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2002             FROM TMTag 
2003             WHERE LOWER(title) = LOWER(?)",
2004        )
2005        .bind(normalized)
2006        .fetch_optional(&self.pool)
2007        .await
2008        .map_err(|e| ThingsError::unknown(format!("Failed to find tag by title: {e}")))?;
2009
2010        if let Some(row) = row {
2011            let uuid_str: String = row.get("uuid");
2012            let uuid =
2013                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2014            let title: String = row.get("title");
2015            let shortcut: Option<String> = row.get("shortcut");
2016            let parent_str: Option<String> = row.get("parent");
2017            let parent_uuid =
2018                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2019
2020            let creation_ts: f64 = row.get("creationDate");
2021            let created = {
2022                let ts = safe_timestamp_convert(creation_ts);
2023                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2024            };
2025
2026            let modification_ts: f64 = row.get("userModificationDate");
2027            let modified = {
2028                let ts = safe_timestamp_convert(modification_ts);
2029                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2030            };
2031
2032            let used_ts: Option<f64> = row.get("usedDate");
2033            let last_used = used_ts.and_then(|ts| {
2034                let ts_i64 = safe_timestamp_convert(ts);
2035                DateTime::from_timestamp(ts_i64, 0)
2036            });
2037
2038            // Count usage by querying tasks with this tag
2039            let usage_count: i64 = sqlx::query_scalar(
2040                "SELECT COUNT(*) FROM TMTask 
2041                 WHERE cachedTags IS NOT NULL 
2042                 AND json_extract(cachedTags, '$') LIKE ?
2043                 AND trashed = 0",
2044            )
2045            .bind(format!("%\"{}\"%", title))
2046            .fetch_one(&self.pool)
2047            .await
2048            .unwrap_or(0);
2049
2050            Ok(Some(crate::models::Tag {
2051                uuid,
2052                title,
2053                shortcut,
2054                parent_uuid,
2055                created,
2056                modified,
2057                usage_count: usage_count as u32,
2058                last_used,
2059            }))
2060        } else {
2061            Ok(None)
2062        }
2063    }
2064
2065    /// Find tags similar to the given title using fuzzy matching
2066    ///
2067    /// Returns tags sorted by similarity score (highest first)
2068    ///
2069    /// # Errors
2070    ///
2071    /// Returns an error if the database query fails
2072    #[instrument(skip(self))]
2073    pub async fn find_similar_tags(
2074        &self,
2075        title: &str,
2076        min_similarity: f32,
2077    ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2078        use crate::database::tag_utils::{calculate_similarity, get_match_type};
2079
2080        // Get all tags
2081        let all_tags = self.get_all_tags().await?;
2082
2083        // Calculate similarity for each tag
2084        let mut matches: Vec<crate::models::TagMatch> = all_tags
2085            .into_iter()
2086            .filter_map(|tag| {
2087                let similarity = calculate_similarity(title, &tag.title);
2088                if similarity >= min_similarity {
2089                    let match_type = get_match_type(title, &tag.title, min_similarity);
2090                    Some(crate::models::TagMatch {
2091                        tag,
2092                        similarity_score: similarity,
2093                        match_type,
2094                    })
2095                } else {
2096                    None
2097                }
2098            })
2099            .collect();
2100
2101        // Sort by similarity score (highest first)
2102        matches.sort_by(|a, b| {
2103            b.similarity_score
2104                .partial_cmp(&a.similarity_score)
2105                .unwrap_or(std::cmp::Ordering::Equal)
2106        });
2107
2108        Ok(matches)
2109    }
2110
2111    /// Search tags by partial title match
2112    ///
2113    /// # Errors
2114    ///
2115    /// Returns an error if the database query fails
2116    #[instrument(skip(self))]
2117    pub async fn search_tags(&self, query: &str) -> ThingsResult<Vec<crate::models::Tag>> {
2118        let rows = sqlx::query(
2119            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2120             FROM TMTag 
2121             WHERE title LIKE ? 
2122             ORDER BY title",
2123        )
2124        .bind(format!("%{}%", query))
2125        .fetch_all(&self.pool)
2126        .await
2127        .map_err(|e| ThingsError::unknown(format!("Failed to search tags: {e}")))?;
2128
2129        let mut tags = Vec::new();
2130        for row in rows {
2131            let uuid_str: String = row.get("uuid");
2132            let uuid =
2133                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2134            let title: String = row.get("title");
2135            let shortcut: Option<String> = row.get("shortcut");
2136            let parent_str: Option<String> = row.get("parent");
2137            let parent_uuid =
2138                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2139
2140            let creation_ts: f64 = row.get("creationDate");
2141            let created = {
2142                let ts = safe_timestamp_convert(creation_ts);
2143                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2144            };
2145
2146            let modification_ts: f64 = row.get("userModificationDate");
2147            let modified = {
2148                let ts = safe_timestamp_convert(modification_ts);
2149                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2150            };
2151
2152            let used_ts: Option<f64> = row.get("usedDate");
2153            let last_used = used_ts.and_then(|ts| {
2154                let ts_i64 = safe_timestamp_convert(ts);
2155                DateTime::from_timestamp(ts_i64, 0)
2156            });
2157
2158            // Count usage
2159            let usage_count: i64 = sqlx::query_scalar(
2160                "SELECT COUNT(*) FROM TMTask 
2161                 WHERE cachedTags IS NOT NULL 
2162                 AND json_extract(cachedTags, '$') LIKE ?
2163                 AND trashed = 0",
2164            )
2165            .bind(format!("%\"{}\"%", title))
2166            .fetch_one(&self.pool)
2167            .await
2168            .unwrap_or(0);
2169
2170            tags.push(crate::models::Tag {
2171                uuid,
2172                title,
2173                shortcut,
2174                parent_uuid,
2175                created,
2176                modified,
2177                usage_count: usage_count as u32,
2178                last_used,
2179            });
2180        }
2181
2182        Ok(tags)
2183    }
2184
2185    /// Get all tags ordered by title
2186    ///
2187    /// # Errors
2188    ///
2189    /// Returns an error if the database query fails
2190    #[instrument(skip(self))]
2191    pub async fn get_all_tags(&self) -> ThingsResult<Vec<crate::models::Tag>> {
2192        let rows = sqlx::query(
2193            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2194             FROM TMTag 
2195             ORDER BY title",
2196        )
2197        .fetch_all(&self.pool)
2198        .await
2199        .map_err(|e| ThingsError::unknown(format!("Failed to get all tags: {e}")))?;
2200
2201        let mut tags = Vec::new();
2202        for row in rows {
2203            let uuid_str: String = row.get("uuid");
2204            let uuid =
2205                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2206            let title: String = row.get("title");
2207            let shortcut: Option<String> = row.get("shortcut");
2208            let parent_str: Option<String> = row.get("parent");
2209            let parent_uuid =
2210                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2211
2212            let creation_ts: f64 = row.get("creationDate");
2213            let created = {
2214                let ts = safe_timestamp_convert(creation_ts);
2215                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2216            };
2217
2218            let modification_ts: f64 = row.get("userModificationDate");
2219            let modified = {
2220                let ts = safe_timestamp_convert(modification_ts);
2221                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2222            };
2223
2224            let used_ts: Option<f64> = row.get("usedDate");
2225            let last_used = used_ts.and_then(|ts| {
2226                let ts_i64 = safe_timestamp_convert(ts);
2227                DateTime::from_timestamp(ts_i64, 0)
2228            });
2229
2230            // Count usage
2231            let usage_count: i64 = sqlx::query_scalar(
2232                "SELECT COUNT(*) FROM TMTask 
2233                 WHERE cachedTags IS NOT NULL 
2234                 AND json_extract(cachedTags, '$') LIKE ?
2235                 AND trashed = 0",
2236            )
2237            .bind(format!("%\"{}\"%", title))
2238            .fetch_one(&self.pool)
2239            .await
2240            .unwrap_or(0);
2241
2242            tags.push(crate::models::Tag {
2243                uuid,
2244                title,
2245                shortcut,
2246                parent_uuid,
2247                created,
2248                modified,
2249                usage_count: usage_count as u32,
2250                last_used,
2251            });
2252        }
2253
2254        Ok(tags)
2255    }
2256
2257    /// Get most frequently used tags
2258    ///
2259    /// # Errors
2260    ///
2261    /// Returns an error if the database query fails
2262    #[instrument(skip(self))]
2263    pub async fn get_popular_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2264        let mut all_tags = self.get_all_tags().await?;
2265
2266        // Sort by usage count (highest first)
2267        all_tags.sort_by(|a, b| b.usage_count.cmp(&a.usage_count));
2268
2269        // Take the top N
2270        all_tags.truncate(limit);
2271
2272        Ok(all_tags)
2273    }
2274
2275    /// Get recently used tags
2276    ///
2277    /// # Errors
2278    ///
2279    /// Returns an error if the database query fails
2280    #[instrument(skip(self))]
2281    pub async fn get_recent_tags(&self, limit: usize) -> ThingsResult<Vec<crate::models::Tag>> {
2282        let rows = sqlx::query(
2283            "SELECT uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate 
2284             FROM TMTag 
2285             WHERE usedDate IS NOT NULL 
2286             ORDER BY usedDate DESC 
2287             LIMIT ?",
2288        )
2289        .bind(limit as i64)
2290        .fetch_all(&self.pool)
2291        .await
2292        .map_err(|e| ThingsError::unknown(format!("Failed to get recent tags: {e}")))?;
2293
2294        let mut tags = Vec::new();
2295        for row in rows {
2296            let uuid_str: String = row.get("uuid");
2297            let uuid =
2298                Uuid::parse_str(&uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
2299            let title: String = row.get("title");
2300            let shortcut: Option<String> = row.get("shortcut");
2301            let parent_str: Option<String> = row.get("parent");
2302            let parent_uuid =
2303                parent_str.map(|s| Uuid::parse_str(&s).unwrap_or_else(|_| things_uuid_to_uuid(&s)));
2304
2305            let creation_ts: f64 = row.get("creationDate");
2306            let created = {
2307                let ts = safe_timestamp_convert(creation_ts);
2308                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2309            };
2310
2311            let modification_ts: f64 = row.get("userModificationDate");
2312            let modified = {
2313                let ts = safe_timestamp_convert(modification_ts);
2314                DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
2315            };
2316
2317            let used_ts: Option<f64> = row.get("usedDate");
2318            let last_used = used_ts.and_then(|ts| {
2319                let ts_i64 = safe_timestamp_convert(ts);
2320                DateTime::from_timestamp(ts_i64, 0)
2321            });
2322
2323            // Count usage
2324            let usage_count: i64 = sqlx::query_scalar(
2325                "SELECT COUNT(*) FROM TMTask 
2326                 WHERE cachedTags IS NOT NULL 
2327                 AND json_extract(cachedTags, '$') LIKE ?
2328                 AND trashed = 0",
2329            )
2330            .bind(format!("%\"{}\"%", title))
2331            .fetch_one(&self.pool)
2332            .await
2333            .unwrap_or(0);
2334
2335            tags.push(crate::models::Tag {
2336                uuid,
2337                title,
2338                shortcut,
2339                parent_uuid,
2340                created,
2341                modified,
2342                usage_count: usage_count as u32,
2343                last_used,
2344            });
2345        }
2346
2347        Ok(tags)
2348    }
2349
2350    /// Create a tag with smart duplicate detection
2351    ///
2352    /// Returns:
2353    /// - `Created`: New tag was created
2354    /// - `Existing`: Exact match found (case-insensitive)
2355    /// - `SimilarFound`: Similar tags found (user decision needed)
2356    ///
2357    /// # Errors
2358    ///
2359    /// Returns an error if the database operation fails
2360    #[instrument(skip(self))]
2361    pub async fn create_tag_smart(
2362        &self,
2363        request: crate::models::CreateTagRequest,
2364    ) -> ThingsResult<crate::models::TagCreationResult> {
2365        use crate::database::tag_utils::normalize_tag_title;
2366        use crate::models::TagCreationResult;
2367
2368        // 1. Normalize the title
2369        let normalized = normalize_tag_title(&request.title);
2370
2371        // 2. Check for exact match (case-insensitive)
2372        if let Some(existing) = self.find_tag_by_normalized_title(&normalized).await? {
2373            return Ok(TagCreationResult::Existing {
2374                tag: existing,
2375                is_new: false,
2376            });
2377        }
2378
2379        // 3. Find similar tags (fuzzy matching with 80% threshold)
2380        let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2381
2382        // 4. If similar tags found, return them for user decision
2383        if !similar_tags.is_empty() {
2384            return Ok(TagCreationResult::SimilarFound {
2385                similar_tags,
2386                requested_title: request.title,
2387            });
2388        }
2389
2390        // 5. No duplicates, safe to create
2391        let uuid = Uuid::new_v4();
2392        let now = Utc::now().timestamp() as f64;
2393
2394        sqlx::query(
2395            "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`) 
2396             VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2397        )
2398        .bind(uuid.to_string())
2399        .bind(&request.title)
2400        .bind(request.shortcut.as_ref())
2401        .bind(request.parent_uuid.map(|u| u.to_string()))
2402        .bind(now)
2403        .bind(now)
2404        .execute(&self.pool)
2405        .await
2406        .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2407
2408        info!("Created tag with UUID: {}", uuid);
2409        Ok(TagCreationResult::Created { uuid, is_new: true })
2410    }
2411
2412    /// Create tag forcefully (skip duplicate check)
2413    ///
2414    /// # Errors
2415    ///
2416    /// Returns an error if the database operation fails
2417    #[instrument(skip(self))]
2418    pub async fn create_tag_force(
2419        &self,
2420        request: crate::models::CreateTagRequest,
2421    ) -> ThingsResult<Uuid> {
2422        let uuid = Uuid::new_v4();
2423        let now = Utc::now().timestamp() as f64;
2424
2425        sqlx::query(
2426            "INSERT INTO TMTag (uuid, title, shortcut, parent, creationDate, userModificationDate, usedDate, `index`) 
2427             VALUES (?, ?, ?, ?, ?, ?, NULL, 0)"
2428        )
2429        .bind(uuid.to_string())
2430        .bind(&request.title)
2431        .bind(request.shortcut.as_ref())
2432        .bind(request.parent_uuid.map(|u| u.to_string()))
2433        .bind(now)
2434        .bind(now)
2435        .execute(&self.pool)
2436        .await
2437        .map_err(|e| ThingsError::unknown(format!("Failed to create tag: {e}")))?;
2438
2439        info!("Forcefully created tag with UUID: {}", uuid);
2440        Ok(uuid)
2441    }
2442
2443    /// Update a tag
2444    ///
2445    /// # Errors
2446    ///
2447    /// Returns an error if the tag doesn't exist or database operation fails
2448    #[instrument(skip(self))]
2449    pub async fn update_tag(&self, request: crate::models::UpdateTagRequest) -> ThingsResult<()> {
2450        use crate::database::tag_utils::normalize_tag_title;
2451
2452        // Verify tag exists
2453        let existing = self
2454            .find_tag_by_normalized_title(&request.uuid.to_string())
2455            .await?;
2456        if existing.is_none() {
2457            // Try by UUID
2458            let row = sqlx::query("SELECT 1 FROM TMTag WHERE uuid = ?")
2459                .bind(request.uuid.to_string())
2460                .fetch_optional(&self.pool)
2461                .await
2462                .map_err(|e| ThingsError::unknown(format!("Failed to validate tag: {e}")))?;
2463
2464            if row.is_none() {
2465                return Err(ThingsError::unknown(format!(
2466                    "Tag not found: {}",
2467                    request.uuid
2468                )));
2469            }
2470        }
2471
2472        // If renaming, check for duplicates with new name
2473        if let Some(new_title) = &request.title {
2474            let normalized = normalize_tag_title(new_title);
2475            if let Some(duplicate) = self.find_tag_by_normalized_title(&normalized).await? {
2476                if duplicate.uuid != request.uuid {
2477                    return Err(ThingsError::unknown(format!(
2478                        "Tag with title '{}' already exists",
2479                        new_title
2480                    )));
2481                }
2482            }
2483        }
2484
2485        let now = Utc::now().timestamp() as f64;
2486
2487        // Build dynamic UPDATE query
2488        let mut updates = Vec::new();
2489        let mut params: Vec<String> = Vec::new();
2490
2491        if let Some(title) = &request.title {
2492            updates.push("title = ?");
2493            params.push(title.clone());
2494        }
2495        if let Some(shortcut) = &request.shortcut {
2496            updates.push("shortcut = ?");
2497            params.push(shortcut.clone());
2498        }
2499        if let Some(parent_uuid) = request.parent_uuid {
2500            updates.push("parent = ?");
2501            params.push(parent_uuid.to_string());
2502        }
2503
2504        if updates.is_empty() {
2505            return Ok(()); // Nothing to update
2506        }
2507
2508        updates.push("userModificationDate = ?");
2509        params.push(now.to_string());
2510
2511        let sql = format!("UPDATE TMTag SET {} WHERE uuid = ?", updates.join(", "));
2512        params.push(request.uuid.to_string());
2513
2514        let mut query = sqlx::query(&sql);
2515        for param in params {
2516            query = query.bind(param);
2517        }
2518
2519        query
2520            .execute(&self.pool)
2521            .await
2522            .map_err(|e| ThingsError::unknown(format!("Failed to update tag: {e}")))?;
2523
2524        info!("Updated tag with UUID: {}", request.uuid);
2525        Ok(())
2526    }
2527
2528    /// Delete a tag
2529    ///
2530    /// # Arguments
2531    ///
2532    /// * `uuid` - UUID of the tag to delete
2533    /// * `remove_from_tasks` - If true, removes tag from all tasks' cachedTags
2534    ///
2535    /// # Errors
2536    ///
2537    /// Returns an error if the database operation fails
2538    #[instrument(skip(self))]
2539    pub async fn delete_tag(&self, uuid: &Uuid, remove_from_tasks: bool) -> ThingsResult<()> {
2540        // Get the tag title before deletion
2541        let tag = self.find_tag_by_normalized_title(&uuid.to_string()).await?;
2542
2543        if tag.is_none() {
2544            // Try by UUID directly
2545            let row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2546                .bind(uuid.to_string())
2547                .fetch_optional(&self.pool)
2548                .await
2549                .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
2550
2551            if row.is_none() {
2552                return Err(ThingsError::unknown(format!("Tag not found: {}", uuid)));
2553            }
2554        }
2555
2556        if remove_from_tasks {
2557            // TODO: Implement updating all tasks' cachedTags to remove this tag
2558            // This requires parsing and re-serializing the JSON arrays
2559            info!("Removing tag {} from all tasks (not yet implemented)", uuid);
2560        }
2561
2562        // Delete the tag
2563        sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2564            .bind(uuid.to_string())
2565            .execute(&self.pool)
2566            .await
2567            .map_err(|e| ThingsError::unknown(format!("Failed to delete tag: {e}")))?;
2568
2569        info!("Deleted tag with UUID: {}", uuid);
2570        Ok(())
2571    }
2572
2573    /// Merge two tags (combine source into target)
2574    ///
2575    /// # Arguments
2576    ///
2577    /// * `source_uuid` - UUID of tag to merge from (will be deleted)
2578    /// * `target_uuid` - UUID of tag to merge into (will remain)
2579    ///
2580    /// # Errors
2581    ///
2582    /// Returns an error if either tag doesn't exist or database operation fails
2583    #[instrument(skip(self))]
2584    pub async fn merge_tags(&self, source_uuid: &Uuid, target_uuid: &Uuid) -> ThingsResult<()> {
2585        // Verify both tags exist
2586        let source_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2587            .bind(source_uuid.to_string())
2588            .fetch_optional(&self.pool)
2589            .await
2590            .map_err(|e| ThingsError::unknown(format!("Failed to find source tag: {e}")))?;
2591
2592        if source_row.is_none() {
2593            return Err(ThingsError::unknown(format!(
2594                "Source tag not found: {}",
2595                source_uuid
2596            )));
2597        }
2598
2599        let target_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2600            .bind(target_uuid.to_string())
2601            .fetch_optional(&self.pool)
2602            .await
2603            .map_err(|e| ThingsError::unknown(format!("Failed to find target tag: {e}")))?;
2604
2605        if target_row.is_none() {
2606            return Err(ThingsError::unknown(format!(
2607                "Target tag not found: {}",
2608                target_uuid
2609            )));
2610        }
2611
2612        // TODO: Implement updating all tasks' cachedTags to replace source tag with target tag
2613        // This requires parsing and re-serializing the JSON arrays
2614        info!(
2615            "Merging tag {} into {} (tag replacement in tasks not yet fully implemented)",
2616            source_uuid, target_uuid
2617        );
2618
2619        // Update usedDate on target if source was used more recently
2620        let now = Utc::now().timestamp() as f64;
2621        sqlx::query("UPDATE TMTag SET userModificationDate = ?, usedDate = ? WHERE uuid = ?")
2622            .bind(now)
2623            .bind(now)
2624            .bind(target_uuid.to_string())
2625            .execute(&self.pool)
2626            .await
2627            .map_err(|e| ThingsError::unknown(format!("Failed to update target tag: {e}")))?;
2628
2629        // Delete source tag
2630        sqlx::query("DELETE FROM TMTag WHERE uuid = ?")
2631            .bind(source_uuid.to_string())
2632            .execute(&self.pool)
2633            .await
2634            .map_err(|e| ThingsError::unknown(format!("Failed to delete source tag: {e}")))?;
2635
2636        info!("Merged tag {} into {}", source_uuid, target_uuid);
2637        Ok(())
2638    }
2639
2640    // ========================================================================
2641    // TAG ASSIGNMENT OPERATIONS
2642    // ========================================================================
2643
2644    /// Add a tag to a task (with duplicate prevention)
2645    ///
2646    /// Returns:
2647    /// - `Assigned`: Tag was successfully assigned
2648    /// - `Suggestions`: Similar tags found (user decision needed)
2649    ///
2650    /// # Errors
2651    ///
2652    /// Returns an error if the task doesn't exist or database operation fails
2653    #[instrument(skip(self))]
2654    pub async fn add_tag_to_task(
2655        &self,
2656        task_uuid: &Uuid,
2657        tag_title: &str,
2658    ) -> ThingsResult<crate::models::TagAssignmentResult> {
2659        use crate::database::tag_utils::normalize_tag_title;
2660        use crate::models::TagAssignmentResult;
2661
2662        // 1. Verify task exists
2663        validators::validate_task_exists(&self.pool, task_uuid).await?;
2664
2665        // 2. Normalize and find tag
2666        let normalized = normalize_tag_title(tag_title);
2667
2668        // 3. Check for exact match first
2669        let tag = if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await?
2670        {
2671            existing_tag
2672        } else {
2673            // 4. Find similar tags
2674            let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2675
2676            if !similar_tags.is_empty() {
2677                return Ok(TagAssignmentResult::Suggestions { similar_tags });
2678            }
2679
2680            // 5. No existing tag found, create new one
2681            let request = crate::models::CreateTagRequest {
2682                title: tag_title.to_string(),
2683                shortcut: None,
2684                parent_uuid: None,
2685            };
2686            let _uuid = self.create_tag_force(request).await?;
2687
2688            // Fetch the newly created tag
2689            self.find_tag_by_normalized_title(&normalized)
2690                .await?
2691                .ok_or_else(|| ThingsError::unknown("Failed to retrieve newly created tag"))?
2692        };
2693
2694        // 6. Get current tags from task
2695        let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2696            .bind(task_uuid.to_string())
2697            .fetch_one(&self.pool)
2698            .await
2699            .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2700
2701        let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2702        let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2703            deserialize_tags_from_blob(&blob)?
2704        } else {
2705            Vec::new()
2706        };
2707
2708        // 7. Add tag if not already present
2709        if !tags.contains(&tag.title) {
2710            tags.push(tag.title.clone());
2711
2712            // 8. Serialize and update
2713            let cached_tags = serialize_tags_to_blob(&tags)?;
2714            let now = Utc::now().timestamp() as f64;
2715
2716            sqlx::query(
2717                "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2718            )
2719            .bind(cached_tags)
2720            .bind(now)
2721            .bind(task_uuid.to_string())
2722            .execute(&self.pool)
2723            .await
2724            .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2725
2726            // 9. Update tag's usedDate
2727            sqlx::query("UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?")
2728                .bind(now)
2729                .bind(now)
2730                .bind(tag.uuid.to_string())
2731                .execute(&self.pool)
2732                .await
2733                .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
2734
2735            info!("Added tag '{}' to task {}", tag.title, task_uuid);
2736        }
2737
2738        Ok(TagAssignmentResult::Assigned { tag_uuid: tag.uuid })
2739    }
2740
2741    /// Remove a tag from a task
2742    ///
2743    /// # Errors
2744    ///
2745    /// Returns an error if the task doesn't exist or database operation fails
2746    #[instrument(skip(self))]
2747    pub async fn remove_tag_from_task(
2748        &self,
2749        task_uuid: &Uuid,
2750        tag_title: &str,
2751    ) -> ThingsResult<()> {
2752        use crate::database::tag_utils::normalize_tag_title;
2753
2754        // 1. Verify task exists
2755        validators::validate_task_exists(&self.pool, task_uuid).await?;
2756
2757        // 2. Get current tags from task
2758        let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
2759            .bind(task_uuid.to_string())
2760            .fetch_one(&self.pool)
2761            .await
2762            .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
2763
2764        let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
2765        let mut tags: Vec<String> = if let Some(blob) = cached_tags_blob {
2766            deserialize_tags_from_blob(&blob)?
2767        } else {
2768            return Ok(()); // No tags to remove
2769        };
2770
2771        // 3. Normalize and find the tag to remove (case-insensitive)
2772        let normalized = normalize_tag_title(tag_title);
2773        let original_len = tags.len();
2774        tags.retain(|t| normalize_tag_title(t) != normalized);
2775
2776        // 4. If tags were actually removed, update the task
2777        if tags.len() < original_len {
2778            let cached_tags = if tags.is_empty() {
2779                None
2780            } else {
2781                Some(serialize_tags_to_blob(&tags)?)
2782            };
2783
2784            let now = Utc::now().timestamp() as f64;
2785
2786            if let Some(cached_tags_val) = cached_tags {
2787                sqlx::query(
2788                    "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2789                )
2790                .bind(cached_tags_val)
2791                .bind(now)
2792                .bind(task_uuid.to_string())
2793                .execute(&self.pool)
2794                .await
2795                .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2796            } else {
2797                // Set cachedTags to NULL if no tags remain
2798                sqlx::query(
2799                    "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
2800                )
2801                .bind(now)
2802                .bind(task_uuid.to_string())
2803                .execute(&self.pool)
2804                .await
2805                .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2806            }
2807
2808            info!("Removed tag '{}' from task {}", tag_title, task_uuid);
2809        }
2810
2811        Ok(())
2812    }
2813
2814    /// Replace all tags on a task (with duplicate prevention)
2815    ///
2816    /// Returns any tag titles that had similar matches for user confirmation
2817    ///
2818    /// # Errors
2819    ///
2820    /// Returns an error if the task doesn't exist or database operation fails
2821    #[instrument(skip(self))]
2822    pub async fn set_task_tags(
2823        &self,
2824        task_uuid: &Uuid,
2825        tag_titles: Vec<String>,
2826    ) -> ThingsResult<Vec<crate::models::TagMatch>> {
2827        use crate::database::tag_utils::normalize_tag_title;
2828
2829        // 1. Verify task exists
2830        validators::validate_task_exists(&self.pool, task_uuid).await?;
2831
2832        let mut resolved_tags = Vec::new();
2833        let mut suggestions = Vec::new();
2834
2835        // 2. Resolve each tag title
2836        for title in tag_titles {
2837            let normalized = normalize_tag_title(&title);
2838
2839            // Try to find exact match
2840            if let Some(existing_tag) = self.find_tag_by_normalized_title(&normalized).await? {
2841                resolved_tags.push(existing_tag.title);
2842            } else {
2843                // Check for similar tags
2844                let similar_tags = self.find_similar_tags(&normalized, 0.8).await?;
2845
2846                if !similar_tags.is_empty() {
2847                    suggestions.extend(similar_tags);
2848                }
2849
2850                // Use the requested title anyway (will create if needed)
2851                resolved_tags.push(title);
2852            }
2853        }
2854
2855        // 3. For any tags that don't exist yet, create them
2856        for title in &resolved_tags {
2857            let normalized = normalize_tag_title(title);
2858            if self
2859                .find_tag_by_normalized_title(&normalized)
2860                .await?
2861                .is_none()
2862            {
2863                let request = crate::models::CreateTagRequest {
2864                    title: title.clone(),
2865                    shortcut: None,
2866                    parent_uuid: None,
2867                };
2868                self.create_tag_force(request).await?;
2869            }
2870        }
2871
2872        // 4. Update task's cachedTags
2873        let cached_tags = if resolved_tags.is_empty() {
2874            None
2875        } else {
2876            Some(serialize_tags_to_blob(&resolved_tags)?)
2877        };
2878
2879        let now = Utc::now().timestamp() as f64;
2880
2881        if let Some(cached_tags_val) = cached_tags {
2882            sqlx::query(
2883                "UPDATE TMTask SET cachedTags = ?, userModificationDate = ? WHERE uuid = ?",
2884            )
2885            .bind(cached_tags_val)
2886            .bind(now)
2887            .bind(task_uuid.to_string())
2888            .execute(&self.pool)
2889            .await
2890            .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2891        } else {
2892            sqlx::query(
2893                "UPDATE TMTask SET cachedTags = NULL, userModificationDate = ? WHERE uuid = ?",
2894            )
2895            .bind(now)
2896            .bind(task_uuid.to_string())
2897            .execute(&self.pool)
2898            .await
2899            .map_err(|e| ThingsError::unknown(format!("Failed to update task tags: {e}")))?;
2900        }
2901
2902        // 5. Update usedDate for all tags
2903        for title in &resolved_tags {
2904            let normalized = normalize_tag_title(title);
2905            if let Some(tag) = self.find_tag_by_normalized_title(&normalized).await? {
2906                sqlx::query(
2907                    "UPDATE TMTag SET usedDate = ?, userModificationDate = ? WHERE uuid = ?",
2908                )
2909                .bind(now)
2910                .bind(now)
2911                .bind(tag.uuid.to_string())
2912                .execute(&self.pool)
2913                .await
2914                .map_err(|e| ThingsError::unknown(format!("Failed to update tag usedDate: {e}")))?;
2915            }
2916        }
2917
2918        info!("Set tags on task {} to: {:?}", task_uuid, resolved_tags);
2919        Ok(suggestions)
2920    }
2921
2922    // ========================================================================
2923    // TAG AUTO-COMPLETION & ANALYTICS
2924    // ========================================================================
2925
2926    /// Get tag completions for partial input
2927    ///
2928    /// Returns tags sorted by:
2929    /// 1. Exact prefix matches (prioritized)
2930    /// 2. Contains matches
2931    /// 3. Fuzzy matches
2932    /// Within each category, sorted by usage frequency
2933    ///
2934    /// # Errors
2935    ///
2936    /// Returns an error if the database query fails
2937    #[instrument(skip(self))]
2938    pub async fn get_tag_completions(
2939        &self,
2940        partial_input: &str,
2941        limit: usize,
2942    ) -> ThingsResult<Vec<crate::models::TagCompletion>> {
2943        use crate::database::tag_utils::{calculate_similarity, normalize_tag_title};
2944
2945        let normalized_input = normalize_tag_title(partial_input);
2946        let all_tags = self.get_all_tags().await?;
2947
2948        let mut completions: Vec<crate::models::TagCompletion> = all_tags
2949            .into_iter()
2950            .filter_map(|tag| {
2951                let normalized_tag = normalize_tag_title(&tag.title);
2952
2953                // Calculate score based on match type
2954                let score = if normalized_tag.starts_with(&normalized_input) {
2955                    // Exact prefix match: highest priority
2956                    3.0 + (tag.usage_count as f32 / 100.0)
2957                } else if normalized_tag.contains(&normalized_input) {
2958                    // Contains match: medium priority
2959                    2.0 + (tag.usage_count as f32 / 100.0)
2960                } else {
2961                    // Fuzzy match: lower priority
2962                    let similarity = calculate_similarity(partial_input, &tag.title);
2963                    if similarity >= 0.6 {
2964                        similarity + (tag.usage_count as f32 / 1000.0)
2965                    } else {
2966                        return None; // Not similar enough
2967                    }
2968                };
2969
2970                Some(crate::models::TagCompletion { tag, score })
2971            })
2972            .collect();
2973
2974        // Sort by score (highest first)
2975        completions.sort_by(|a, b| {
2976            b.score
2977                .partial_cmp(&a.score)
2978                .unwrap_or(std::cmp::Ordering::Equal)
2979        });
2980
2981        // Take the top N
2982        completions.truncate(limit);
2983
2984        Ok(completions)
2985    }
2986
2987    /// Get detailed statistics for a tag
2988    ///
2989    /// # Errors
2990    ///
2991    /// Returns an error if the tag doesn't exist or database query fails
2992    #[instrument(skip(self))]
2993    pub async fn get_tag_statistics(
2994        &self,
2995        uuid: &Uuid,
2996    ) -> ThingsResult<crate::models::TagStatistics> {
2997        // Get the tag
2998        let tag_row = sqlx::query("SELECT title FROM TMTag WHERE uuid = ?")
2999            .bind(uuid.to_string())
3000            .fetch_optional(&self.pool)
3001            .await
3002            .map_err(|e| ThingsError::unknown(format!("Failed to find tag: {e}")))?;
3003
3004        let title: String = tag_row
3005            .ok_or_else(|| ThingsError::unknown(format!("Tag not found: {}", uuid)))?
3006            .get("title");
3007
3008        // Get all tasks using this tag
3009        // Note: We query cachedTags BLOB which should contain JSON, but handle gracefully if malformed
3010        let task_rows = sqlx::query(
3011            "SELECT uuid, cachedTags FROM TMTask 
3012             WHERE cachedTags IS NOT NULL 
3013             AND trashed = 0",
3014        )
3015        .fetch_all(&self.pool)
3016        .await
3017        .map_err(|e| ThingsError::unknown(format!("Failed to query tasks with tag: {e}")))?;
3018
3019        let mut task_uuids = Vec::new();
3020        for row in task_rows {
3021            let uuid_str: String = row.get("uuid");
3022            let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3023
3024            // Check if this task actually has the tag
3025            if let Some(blob) = cached_tags_blob {
3026                if let Ok(tags) = deserialize_tags_from_blob(&blob) {
3027                    if tags.iter().any(|t| t.eq_ignore_ascii_case(&title)) {
3028                        let task_uuid = Uuid::parse_str(&uuid_str)
3029                            .unwrap_or_else(|_| things_uuid_to_uuid(&uuid_str));
3030                        task_uuids.push(task_uuid);
3031                    }
3032                }
3033            }
3034        }
3035
3036        let usage_count = task_uuids.len() as u32;
3037
3038        // Find related tags (tags that frequently appear with this tag)
3039        let mut related_tags: std::collections::HashMap<String, u32> =
3040            std::collections::HashMap::new();
3041
3042        for task_uuid in &task_uuids {
3043            let row = sqlx::query("SELECT cachedTags FROM TMTask WHERE uuid = ?")
3044                .bind(task_uuid.to_string())
3045                .fetch_optional(&self.pool)
3046                .await
3047                .map_err(|e| ThingsError::unknown(format!("Failed to fetch task tags: {e}")))?;
3048
3049            if let Some(row) = row {
3050                let cached_tags_blob: Option<Vec<u8>> = row.get("cachedTags");
3051                if let Some(blob) = cached_tags_blob {
3052                    let tags: Vec<String> = deserialize_tags_from_blob(&blob)?;
3053                    for tag in tags {
3054                        if tag != title {
3055                            *related_tags.entry(tag).or_insert(0) += 1;
3056                        }
3057                    }
3058                }
3059            }
3060        }
3061
3062        // Sort related tags by co-occurrence count
3063        let mut related_vec: Vec<(String, u32)> = related_tags.into_iter().collect();
3064        related_vec.sort_by(|a, b| b.1.cmp(&a.1));
3065
3066        Ok(crate::models::TagStatistics {
3067            uuid: *uuid,
3068            title,
3069            usage_count,
3070            task_uuids,
3071            related_tags: related_vec,
3072        })
3073    }
3074
3075    /// Find duplicate or highly similar tags
3076    ///
3077    /// Returns pairs of tags that are similar above the threshold
3078    ///
3079    /// # Errors
3080    ///
3081    /// Returns an error if the database query fails
3082    #[instrument(skip(self))]
3083    pub async fn find_duplicate_tags(
3084        &self,
3085        min_similarity: f32,
3086    ) -> ThingsResult<Vec<crate::models::TagPair>> {
3087        use crate::database::tag_utils::calculate_similarity;
3088
3089        let all_tags = self.get_all_tags().await?;
3090        let mut pairs = Vec::new();
3091
3092        // Compare each tag with every other tag
3093        for i in 0..all_tags.len() {
3094            for j in (i + 1)..all_tags.len() {
3095                let tag1 = &all_tags[i];
3096                let tag2 = &all_tags[j];
3097
3098                let similarity = calculate_similarity(&tag1.title, &tag2.title);
3099
3100                if similarity >= min_similarity {
3101                    pairs.push(crate::models::TagPair {
3102                        tag1: tag1.clone(),
3103                        tag2: tag2.clone(),
3104                        similarity,
3105                    });
3106                }
3107            }
3108        }
3109
3110        // Sort by similarity (highest first)
3111        pairs.sort_by(|a, b| {
3112            b.similarity
3113                .partial_cmp(&a.similarity)
3114                .unwrap_or(std::cmp::Ordering::Equal)
3115        });
3116
3117        Ok(pairs)
3118    }
3119
3120    // ============================================================================
3121    // Bulk Operations
3122    // ============================================================================
3123
3124    /// Maximum number of tasks that can be processed in a single bulk operation
3125    /// This prevents abuse and ensures reasonable transaction sizes
3126    const MAX_BULK_BATCH_SIZE: usize = 1000;
3127
3128    /// Move multiple tasks to a project or area (transactional)
3129    ///
3130    /// All tasks must exist and be valid, or the entire operation will be rolled back.
3131    ///
3132    /// # Errors
3133    ///
3134    /// Returns an error if:
3135    /// - Task UUIDs array is empty
3136    /// - Neither project_uuid nor area_uuid is specified
3137    /// - Target project or area doesn't exist
3138    /// - Any task UUID is invalid or doesn't exist
3139    /// - Database operation fails
3140    #[instrument(skip(self))]
3141    pub async fn bulk_move(
3142        &self,
3143        request: crate::models::BulkMoveRequest,
3144    ) -> ThingsResult<crate::models::BulkOperationResult> {
3145        // Validation
3146        if request.task_uuids.is_empty() {
3147            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3148        }
3149        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3150            return Err(ThingsError::validation(format!(
3151                "Batch size {} exceeds maximum of {}",
3152                request.task_uuids.len(),
3153                Self::MAX_BULK_BATCH_SIZE
3154            )));
3155        }
3156        if request.project_uuid.is_none() && request.area_uuid.is_none() {
3157            return Err(ThingsError::validation(
3158                "Must specify either project_uuid or area_uuid",
3159            ));
3160        }
3161
3162        // Validate target project/area exists
3163        if let Some(project_uuid) = &request.project_uuid {
3164            validators::validate_project_exists(&self.pool, project_uuid).await?;
3165        }
3166        if let Some(area_uuid) = &request.area_uuid {
3167            validators::validate_area_exists(&self.pool, area_uuid).await?;
3168        }
3169
3170        // Begin transaction
3171        let mut tx = self
3172            .pool
3173            .begin()
3174            .await
3175            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3176
3177        // Validate all tasks exist in a single batch query (prevent N+1)
3178        let placeholders = request
3179            .task_uuids
3180            .iter()
3181            .map(|_| "?")
3182            .collect::<Vec<_>>()
3183            .join(",");
3184        let query_str = format!(
3185            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3186            placeholders
3187        );
3188
3189        let mut query = sqlx::query(&query_str);
3190        for uuid in &request.task_uuids {
3191            query = query.bind(uuid.to_string());
3192        }
3193
3194        let found_uuids: Vec<String> = query
3195            .fetch_all(&mut *tx)
3196            .await
3197            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3198            .iter()
3199            .map(|row| row.get("uuid"))
3200            .collect();
3201
3202        // Check if any UUIDs were not found
3203        if found_uuids.len() != request.task_uuids.len() {
3204            // Find the first missing UUID for error reporting
3205            for uuid in &request.task_uuids {
3206                if !found_uuids.contains(&uuid.to_string()) {
3207                    tx.rollback().await.ok();
3208                    return Err(ThingsError::TaskNotFound {
3209                        uuid: uuid.to_string(),
3210                    });
3211                }
3212            }
3213        }
3214
3215        // Batch update
3216        let now = Utc::now().timestamp() as f64;
3217        let placeholders = request
3218            .task_uuids
3219            .iter()
3220            .map(|_| "?")
3221            .collect::<Vec<_>>()
3222            .join(",");
3223        let query_str = format!(
3224            "UPDATE TMTask SET project = ?, area = ?, userModificationDate = ? WHERE uuid IN ({})",
3225            placeholders
3226        );
3227
3228        let mut query = sqlx::query(&query_str)
3229            .bind(request.project_uuid.map(|u| u.to_string()))
3230            .bind(request.area_uuid.map(|u| u.to_string()))
3231            .bind(now);
3232
3233        for uuid in &request.task_uuids {
3234            query = query.bind(uuid.to_string());
3235        }
3236
3237        query
3238            .execute(&mut *tx)
3239            .await
3240            .map_err(|e| ThingsError::unknown(format!("Failed to bulk move tasks: {e}")))?;
3241
3242        // Commit transaction
3243        tx.commit()
3244            .await
3245            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3246
3247        info!("Bulk moved {} task(s)", request.task_uuids.len());
3248        Ok(crate::models::BulkOperationResult {
3249            success: true,
3250            processed_count: request.task_uuids.len(),
3251            message: format!("Successfully moved {} task(s)", request.task_uuids.len()),
3252        })
3253    }
3254
3255    /// Update dates for multiple tasks with validation (transactional)
3256    ///
3257    /// All tasks must exist and dates must be valid, or the entire operation will be rolled back.
3258    /// Validates that deadline >= start_date for each task after merging with existing dates.
3259    ///
3260    /// # Errors
3261    ///
3262    /// Returns an error if:
3263    /// - Task UUIDs array is empty
3264    /// - Any task UUID is invalid or doesn't exist
3265    /// - Date range validation fails (deadline before start_date)
3266    /// - Database operation fails
3267    #[instrument(skip(self))]
3268    pub async fn bulk_update_dates(
3269        &self,
3270        request: crate::models::BulkUpdateDatesRequest,
3271    ) -> ThingsResult<crate::models::BulkOperationResult> {
3272        use crate::database::{safe_things_date_to_naive_date, validate_date_range};
3273
3274        // Validation
3275        if request.task_uuids.is_empty() {
3276            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3277        }
3278        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3279            return Err(ThingsError::validation(format!(
3280                "Batch size {} exceeds maximum of {}",
3281                request.task_uuids.len(),
3282                Self::MAX_BULK_BATCH_SIZE
3283            )));
3284        }
3285
3286        // Validate date range if both are provided
3287        if let (Some(start), Some(deadline)) = (request.start_date, request.deadline) {
3288            validate_date_range(Some(start), Some(deadline))?;
3289        }
3290
3291        // Begin transaction
3292        let mut tx = self
3293            .pool
3294            .begin()
3295            .await
3296            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3297
3298        // Validate all tasks exist and check merged date validity in a single batch query
3299        let placeholders = request
3300            .task_uuids
3301            .iter()
3302            .map(|_| "?")
3303            .collect::<Vec<_>>()
3304            .join(",");
3305        let query_str = format!(
3306            "SELECT uuid, startDate, deadline FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3307            placeholders
3308        );
3309
3310        let mut query = sqlx::query(&query_str);
3311        for uuid in &request.task_uuids {
3312            query = query.bind(uuid.to_string());
3313        }
3314
3315        let rows = query
3316            .fetch_all(&mut *tx)
3317            .await
3318            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?;
3319
3320        // Check if all UUIDs were found
3321        if rows.len() != request.task_uuids.len() {
3322            // Find the first missing UUID for error reporting
3323            let found_uuids: Vec<String> = rows.iter().map(|row| row.get("uuid")).collect();
3324            for uuid in &request.task_uuids {
3325                if !found_uuids.contains(&uuid.to_string()) {
3326                    tx.rollback().await.ok();
3327                    return Err(ThingsError::TaskNotFound {
3328                        uuid: uuid.to_string(),
3329                    });
3330                }
3331            }
3332        }
3333
3334        // Validate merged dates for all tasks
3335        for row in &rows {
3336            let current_start: Option<i64> = row.get("startDate");
3337            let current_deadline: Option<i64> = row.get("deadline");
3338
3339            let final_start = if request.clear_start_date {
3340                None
3341            } else if let Some(new_start) = request.start_date {
3342                Some(new_start)
3343            } else {
3344                current_start.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3345            };
3346
3347            let final_deadline = if request.clear_deadline {
3348                None
3349            } else if let Some(new_deadline) = request.deadline {
3350                Some(new_deadline)
3351            } else {
3352                current_deadline.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
3353            };
3354
3355            validate_date_range(final_start, final_deadline)?;
3356        }
3357
3358        // Build and execute batch update
3359        let now = Utc::now().timestamp() as f64;
3360        let placeholders = request
3361            .task_uuids
3362            .iter()
3363            .map(|_| "?")
3364            .collect::<Vec<_>>()
3365            .join(",");
3366
3367        let start_date_value = if request.clear_start_date {
3368            None
3369        } else {
3370            request.start_date.map(naive_date_to_things_timestamp)
3371        };
3372
3373        let deadline_value = if request.clear_deadline {
3374            None
3375        } else {
3376            request.deadline.map(naive_date_to_things_timestamp)
3377        };
3378
3379        let query_str = format!(
3380            "UPDATE TMTask SET startDate = ?, deadline = ?, userModificationDate = ? WHERE uuid IN ({})",
3381            placeholders
3382        );
3383
3384        let mut query = sqlx::query(&query_str)
3385            .bind(start_date_value)
3386            .bind(deadline_value)
3387            .bind(now);
3388
3389        for uuid in &request.task_uuids {
3390            query = query.bind(uuid.to_string());
3391        }
3392
3393        query
3394            .execute(&mut *tx)
3395            .await
3396            .map_err(|e| ThingsError::unknown(format!("Failed to bulk update dates: {e}")))?;
3397
3398        tx.commit()
3399            .await
3400            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3401
3402        info!(
3403            "Bulk updated dates for {} task(s)",
3404            request.task_uuids.len()
3405        );
3406        Ok(crate::models::BulkOperationResult {
3407            success: true,
3408            processed_count: request.task_uuids.len(),
3409            message: format!(
3410                "Successfully updated dates for {} task(s)",
3411                request.task_uuids.len()
3412            ),
3413        })
3414    }
3415
3416    /// Complete multiple tasks (transactional)
3417    ///
3418    /// All tasks must exist, or the entire operation will be rolled back.
3419    ///
3420    /// # Errors
3421    ///
3422    /// Returns an error if:
3423    /// - Task UUIDs array is empty
3424    /// - Any task UUID is invalid or doesn't exist
3425    /// - Database operation fails
3426    #[instrument(skip(self))]
3427    pub async fn bulk_complete(
3428        &self,
3429        request: crate::models::BulkCompleteRequest,
3430    ) -> ThingsResult<crate::models::BulkOperationResult> {
3431        // Validation
3432        if request.task_uuids.is_empty() {
3433            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3434        }
3435        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3436            return Err(ThingsError::validation(format!(
3437                "Batch size {} exceeds maximum of {}",
3438                request.task_uuids.len(),
3439                Self::MAX_BULK_BATCH_SIZE
3440            )));
3441        }
3442
3443        // Begin transaction
3444        let mut tx = self
3445            .pool
3446            .begin()
3447            .await
3448            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3449
3450        // Validate all tasks exist in a single batch query (prevent N+1)
3451        let placeholders = request
3452            .task_uuids
3453            .iter()
3454            .map(|_| "?")
3455            .collect::<Vec<_>>()
3456            .join(",");
3457        let query_str = format!(
3458            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3459            placeholders
3460        );
3461
3462        let mut query = sqlx::query(&query_str);
3463        for uuid in &request.task_uuids {
3464            query = query.bind(uuid.to_string());
3465        }
3466
3467        let found_uuids: Vec<String> = query
3468            .fetch_all(&mut *tx)
3469            .await
3470            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3471            .iter()
3472            .map(|row| row.get("uuid"))
3473            .collect();
3474
3475        // Check if any UUIDs were not found
3476        if found_uuids.len() != request.task_uuids.len() {
3477            // Find the first missing UUID for error reporting
3478            for uuid in &request.task_uuids {
3479                if !found_uuids.contains(&uuid.to_string()) {
3480                    tx.rollback().await.ok();
3481                    return Err(ThingsError::TaskNotFound {
3482                        uuid: uuid.to_string(),
3483                    });
3484                }
3485            }
3486        }
3487
3488        // Batch update: mark as completed
3489        let now = Utc::now().timestamp() as f64;
3490        let placeholders = request
3491            .task_uuids
3492            .iter()
3493            .map(|_| "?")
3494            .collect::<Vec<_>>()
3495            .join(",");
3496        let query_str = format!(
3497            "UPDATE TMTask SET status = 1, stopDate = ?, userModificationDate = ? WHERE uuid IN ({})",
3498            placeholders
3499        );
3500
3501        let mut query = sqlx::query(&query_str).bind(now).bind(now);
3502
3503        for uuid in &request.task_uuids {
3504            query = query.bind(uuid.to_string());
3505        }
3506
3507        query
3508            .execute(&mut *tx)
3509            .await
3510            .map_err(|e| ThingsError::unknown(format!("Failed to bulk complete tasks: {e}")))?;
3511
3512        // Commit transaction
3513        tx.commit()
3514            .await
3515            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3516
3517        info!("Bulk completed {} task(s)", request.task_uuids.len());
3518        Ok(crate::models::BulkOperationResult {
3519            success: true,
3520            processed_count: request.task_uuids.len(),
3521            message: format!(
3522                "Successfully completed {} task(s)",
3523                request.task_uuids.len()
3524            ),
3525        })
3526    }
3527
3528    /// Delete multiple tasks (soft delete, transactional)
3529    ///
3530    /// All tasks must exist, or the entire operation will be rolled back.
3531    ///
3532    /// # Errors
3533    ///
3534    /// Returns an error if:
3535    /// - Task UUIDs array is empty
3536    /// - Any task UUID is invalid or doesn't exist
3537    /// - Database operation fails
3538    #[instrument(skip(self))]
3539    pub async fn bulk_delete(
3540        &self,
3541        request: crate::models::BulkDeleteRequest,
3542    ) -> ThingsResult<crate::models::BulkOperationResult> {
3543        // Validation
3544        if request.task_uuids.is_empty() {
3545            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
3546        }
3547        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
3548            return Err(ThingsError::validation(format!(
3549                "Batch size {} exceeds maximum of {}",
3550                request.task_uuids.len(),
3551                Self::MAX_BULK_BATCH_SIZE
3552            )));
3553        }
3554
3555        // Begin transaction
3556        let mut tx = self
3557            .pool
3558            .begin()
3559            .await
3560            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
3561
3562        // Validate all tasks exist in a single batch query (prevent N+1)
3563        let placeholders = request
3564            .task_uuids
3565            .iter()
3566            .map(|_| "?")
3567            .collect::<Vec<_>>()
3568            .join(",");
3569        let query_str = format!(
3570            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
3571            placeholders
3572        );
3573
3574        let mut query = sqlx::query(&query_str);
3575        for uuid in &request.task_uuids {
3576            query = query.bind(uuid.to_string());
3577        }
3578
3579        let found_uuids: Vec<String> = query
3580            .fetch_all(&mut *tx)
3581            .await
3582            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
3583            .iter()
3584            .map(|row| row.get("uuid"))
3585            .collect();
3586
3587        // Check if any UUIDs were not found
3588        if found_uuids.len() != request.task_uuids.len() {
3589            // Find the first missing UUID for error reporting
3590            for uuid in &request.task_uuids {
3591                if !found_uuids.contains(&uuid.to_string()) {
3592                    tx.rollback().await.ok();
3593                    return Err(ThingsError::TaskNotFound {
3594                        uuid: uuid.to_string(),
3595                    });
3596                }
3597            }
3598        }
3599
3600        // Batch update: soft delete
3601        let now = Utc::now().timestamp() as f64;
3602        let placeholders = request
3603            .task_uuids
3604            .iter()
3605            .map(|_| "?")
3606            .collect::<Vec<_>>()
3607            .join(",");
3608        let query_str = format!(
3609            "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid IN ({})",
3610            placeholders
3611        );
3612
3613        let mut query = sqlx::query(&query_str).bind(now);
3614
3615        for uuid in &request.task_uuids {
3616            query = query.bind(uuid.to_string());
3617        }
3618
3619        query
3620            .execute(&mut *tx)
3621            .await
3622            .map_err(|e| ThingsError::unknown(format!("Failed to bulk delete tasks: {e}")))?;
3623
3624        // Commit transaction
3625        tx.commit()
3626            .await
3627            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
3628
3629        info!("Bulk deleted {} task(s)", request.task_uuids.len());
3630        Ok(crate::models::BulkOperationResult {
3631            success: true,
3632            processed_count: request.task_uuids.len(),
3633            message: format!("Successfully deleted {} task(s)", request.task_uuids.len()),
3634        })
3635    }
3636}
3637
3638/// Database statistics
3639#[derive(Debug, Clone, Serialize, Deserialize)]
3640pub struct DatabaseStats {
3641    pub task_count: u64,
3642    pub project_count: u64,
3643    pub area_count: u64,
3644}
3645
3646impl DatabaseStats {
3647    #[must_use]
3648    pub fn total_items(&self) -> u64 {
3649        self.task_count + self.project_count + self.area_count
3650    }
3651}
3652
3653/// Get the default Things 3 database path
3654///
3655/// # Examples
3656///
3657/// ```
3658/// use things3_core::get_default_database_path;
3659///
3660/// let path = get_default_database_path();
3661/// assert!(!path.to_string_lossy().is_empty());
3662/// assert!(path.to_string_lossy().contains("Library"));
3663/// ```
3664#[must_use]
3665pub fn get_default_database_path() -> PathBuf {
3666    let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
3667    PathBuf::from(format!(
3668        "{home}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-0Z0Z2/Things Database.thingsdatabase/main.sqlite"
3669    ))
3670}
3671
3672#[cfg(test)]
3673mod tests {
3674    use super::*;
3675    use tempfile::{NamedTempFile, TempDir};
3676
3677    #[tokio::test]
3678    async fn test_database_connection() {
3679        let temp_dir = TempDir::new().unwrap();
3680        let db_path = temp_dir.path().join("test.db");
3681
3682        // This will fail because the database doesn't exist yet
3683        // In a real implementation, we'd need to create the schema first
3684        let result = super::ThingsDatabase::new(&db_path).await;
3685        assert!(result.is_err());
3686    }
3687
3688    #[tokio::test]
3689    async fn test_connection_string() {
3690        let result = super::ThingsDatabase::from_connection_string("sqlite::memory:").await;
3691        assert!(result.is_ok());
3692    }
3693
3694    #[test]
3695    fn test_task_status_from_i32() {
3696        assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
3697        assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
3698        assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
3699        assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
3700        assert_eq!(TaskStatus::from_i32(4), None);
3701        assert_eq!(TaskStatus::from_i32(-1), None);
3702    }
3703
3704    #[test]
3705    fn test_task_type_from_i32() {
3706        assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
3707        assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
3708        assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
3709        assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
3710        assert_eq!(TaskType::from_i32(4), None);
3711        assert_eq!(TaskType::from_i32(-1), None);
3712    }
3713
3714    #[test]
3715    fn test_database_stats_total_items() {
3716        let stats = DatabaseStats {
3717            task_count: 10,
3718            project_count: 5,
3719            area_count: 3,
3720        };
3721        assert_eq!(stats.total_items(), 18);
3722
3723        let empty_stats = DatabaseStats {
3724            task_count: 0,
3725            project_count: 0,
3726            area_count: 0,
3727        };
3728        assert_eq!(empty_stats.total_items(), 0);
3729    }
3730
3731    #[test]
3732    fn test_database_pool_config_default() {
3733        let config = DatabasePoolConfig::default();
3734        assert_eq!(config.max_connections, 10);
3735        assert_eq!(config.min_connections, 1);
3736        assert_eq!(config.connect_timeout, Duration::from_secs(30));
3737        assert_eq!(config.idle_timeout, Duration::from_secs(600));
3738        assert_eq!(config.max_lifetime, Duration::from_secs(1800));
3739        assert!(config.test_before_acquire);
3740    }
3741
3742    #[test]
3743    fn test_sqlite_optimizations_default() {
3744        let opts = SqliteOptimizations::default();
3745        assert!(opts.enable_wal_mode);
3746        assert_eq!(opts.cache_size, -20000);
3747        assert_eq!(opts.synchronous_mode, "NORMAL".to_string());
3748        assert_eq!(opts.temp_store, "MEMORY".to_string());
3749        assert_eq!(opts.journal_mode, "WAL".to_string());
3750        assert_eq!(opts.mmap_size, 268_435_456);
3751        assert!(opts.enable_foreign_keys);
3752        assert!(opts.enable_query_planner);
3753    }
3754
3755    #[test]
3756    fn test_pool_health_status_creation() {
3757        let status = PoolHealthStatus {
3758            is_healthy: true,
3759            pool_size: 8,
3760            active_connections: 5,
3761            idle_connections: 3,
3762            max_connections: 10,
3763            min_connections: 1,
3764            connection_timeout: Duration::from_secs(30),
3765            idle_timeout: Some(Duration::from_secs(600)),
3766            max_lifetime: Some(Duration::from_secs(1800)),
3767        };
3768        assert!(status.is_healthy);
3769        assert_eq!(status.active_connections, 5);
3770        assert_eq!(status.idle_connections, 3);
3771        assert_eq!(status.pool_size, 8);
3772    }
3773
3774    #[test]
3775    fn test_pool_metrics_creation() {
3776        let metrics = PoolMetrics {
3777            pool_size: 8,
3778            active_connections: 5,
3779            idle_connections: 3,
3780            max_connections: 10,
3781            min_connections: 1,
3782            utilization_percentage: 80.0,
3783            is_healthy: true,
3784            response_time_ms: 50,
3785            connection_timeout: Duration::from_secs(30),
3786            idle_timeout: Some(Duration::from_secs(600)),
3787            max_lifetime: Some(Duration::from_secs(1800)),
3788        };
3789        assert!(metrics.is_healthy);
3790        assert_eq!(metrics.pool_size, 8);
3791        assert_eq!(metrics.active_connections, 5);
3792        assert_eq!(metrics.idle_connections, 3);
3793        assert!((metrics.utilization_percentage - 80.0).abs() < f64::EPSILON);
3794        assert_eq!(metrics.response_time_ms, 50);
3795    }
3796
3797    #[test]
3798    fn test_comprehensive_health_status_creation() {
3799        let pool_health = PoolHealthStatus {
3800            is_healthy: true,
3801            pool_size: 8,
3802            active_connections: 5,
3803            idle_connections: 3,
3804            max_connections: 10,
3805            min_connections: 1,
3806            connection_timeout: Duration::from_secs(30),
3807            idle_timeout: Some(Duration::from_secs(600)),
3808            max_lifetime: Some(Duration::from_secs(1800)),
3809        };
3810
3811        let pool_metrics = PoolMetrics {
3812            pool_size: 8,
3813            active_connections: 5,
3814            idle_connections: 3,
3815            max_connections: 10,
3816            min_connections: 1,
3817            utilization_percentage: 80.0,
3818            is_healthy: true,
3819            response_time_ms: 50,
3820            connection_timeout: Duration::from_secs(30),
3821            idle_timeout: Some(Duration::from_secs(600)),
3822            max_lifetime: Some(Duration::from_secs(1800)),
3823        };
3824
3825        let db_stats = DatabaseStats {
3826            task_count: 50,
3827            project_count: 10,
3828            area_count: 5,
3829        };
3830
3831        let health_status = ComprehensiveHealthStatus {
3832            overall_healthy: true,
3833            pool_health,
3834            pool_metrics,
3835            database_stats: db_stats,
3836            timestamp: Utc::now(),
3837        };
3838
3839        assert!(health_status.overall_healthy);
3840        assert_eq!(health_status.database_stats.total_items(), 65);
3841    }
3842
3843    #[test]
3844    fn test_safe_timestamp_convert_edge_cases() {
3845        // Test normal timestamp
3846        assert_eq!(safe_timestamp_convert(1_609_459_200.0), 1_609_459_200); // 2021-01-01
3847
3848        // Test zero
3849        assert_eq!(safe_timestamp_convert(0.0), 0);
3850
3851        // Test negative (should return 0)
3852        assert_eq!(safe_timestamp_convert(-1.0), 0);
3853
3854        // Test infinity (should return 0)
3855        assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
3856
3857        // Test NaN (should return 0)
3858        assert_eq!(safe_timestamp_convert(f64::NAN), 0);
3859
3860        // Test very large timestamp (should return 0)
3861        assert_eq!(safe_timestamp_convert(5_000_000_000.0), 0);
3862
3863        // Test max valid timestamp
3864        let max_timestamp = 4_102_444_800_f64; // 2100-01-01
3865        assert_eq!(safe_timestamp_convert(max_timestamp), 4_102_444_800);
3866    }
3867
3868    #[test]
3869    fn test_things_uuid_to_uuid_consistency() {
3870        // Test consistent UUID generation
3871        let things_id = "test-id-123";
3872        let uuid1 = things_uuid_to_uuid(things_id);
3873        let uuid2 = things_uuid_to_uuid(things_id);
3874        assert_eq!(uuid1, uuid2, "UUIDs should be consistent for same input");
3875
3876        // Test different inputs produce different UUIDs
3877        let uuid3 = things_uuid_to_uuid("different-id");
3878        assert_ne!(
3879            uuid1, uuid3,
3880            "Different inputs should produce different UUIDs"
3881        );
3882
3883        // Test empty string
3884        let uuid_empty = things_uuid_to_uuid("");
3885        assert!(!uuid_empty.to_string().is_empty());
3886
3887        // Test very long string
3888        let long_string = "a".repeat(1000);
3889        let uuid_long = things_uuid_to_uuid(&long_string);
3890        assert!(!uuid_long.to_string().is_empty());
3891    }
3892
3893    #[test]
3894    fn test_task_status_from_i32_all_variants() {
3895        assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
3896        assert_eq!(TaskStatus::from_i32(1), Some(TaskStatus::Completed));
3897        assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
3898        assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Trashed));
3899        assert_eq!(TaskStatus::from_i32(999), None);
3900        assert_eq!(TaskStatus::from_i32(-1), None);
3901    }
3902
3903    #[test]
3904    fn test_task_type_from_i32_all_variants() {
3905        assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
3906        assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
3907        assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
3908        assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
3909        assert_eq!(TaskType::from_i32(999), None);
3910        assert_eq!(TaskType::from_i32(-1), None);
3911    }
3912
3913    #[test]
3914    fn test_database_pool_config_default_values() {
3915        let config = DatabasePoolConfig::default();
3916        assert_eq!(config.max_connections, 10);
3917        assert_eq!(config.min_connections, 1);
3918        assert_eq!(config.connect_timeout, Duration::from_secs(30));
3919        assert_eq!(config.idle_timeout, Duration::from_secs(600));
3920        assert_eq!(config.max_lifetime, Duration::from_secs(1800));
3921        assert!(config.test_before_acquire);
3922    }
3923
3924    #[test]
3925    fn test_database_stats_total_items_calculation() {
3926        let stats = DatabaseStats {
3927            task_count: 10,
3928            project_count: 5,
3929            area_count: 3,
3930        };
3931        assert_eq!(stats.total_items(), 18); // 10 + 5 + 3
3932
3933        // Test with zero values
3934        let empty_stats = DatabaseStats {
3935            task_count: 0,
3936            project_count: 0,
3937            area_count: 0,
3938        };
3939        assert_eq!(empty_stats.total_items(), 0);
3940    }
3941
3942    #[test]
3943    fn test_pool_health_status_creation_comprehensive() {
3944        let status = PoolHealthStatus {
3945            is_healthy: true,
3946            pool_size: 8,
3947            active_connections: 2,
3948            idle_connections: 3,
3949            max_connections: 10,
3950            min_connections: 1,
3951            connection_timeout: Duration::from_secs(30),
3952            idle_timeout: Some(Duration::from_secs(600)),
3953            max_lifetime: Some(Duration::from_secs(1800)),
3954        };
3955        assert!(status.is_healthy);
3956        assert_eq!(status.pool_size, 8);
3957        assert_eq!(status.max_connections, 10);
3958    }
3959
3960    #[test]
3961    fn test_pool_metrics_creation_comprehensive() {
3962        let metrics = PoolMetrics {
3963            pool_size: 8,
3964            active_connections: 5,
3965            idle_connections: 3,
3966            max_connections: 10,
3967            min_connections: 1,
3968            utilization_percentage: 80.0,
3969            is_healthy: true,
3970            response_time_ms: 50,
3971            connection_timeout: Duration::from_secs(30),
3972            idle_timeout: Some(Duration::from_secs(600)),
3973            max_lifetime: Some(Duration::from_secs(1800)),
3974        };
3975        assert_eq!(metrics.pool_size, 8);
3976        assert_eq!(metrics.response_time_ms, 50);
3977        assert!(metrics.is_healthy);
3978    }
3979
3980    #[test]
3981    fn test_comprehensive_health_status_creation_full() {
3982        let pool_health = PoolHealthStatus {
3983            is_healthy: true,
3984            pool_size: 8,
3985            active_connections: 2,
3986            idle_connections: 3,
3987            max_connections: 10,
3988            min_connections: 1,
3989            connection_timeout: Duration::from_secs(30),
3990            idle_timeout: Some(Duration::from_secs(600)),
3991            max_lifetime: Some(Duration::from_secs(1800)),
3992        };
3993
3994        let pool_metrics = PoolMetrics {
3995            pool_size: 8,
3996            active_connections: 5,
3997            idle_connections: 3,
3998            max_connections: 10,
3999            min_connections: 1,
4000            utilization_percentage: 80.0,
4001            is_healthy: true,
4002            response_time_ms: 50,
4003            connection_timeout: Duration::from_secs(30),
4004            idle_timeout: Some(Duration::from_secs(600)),
4005            max_lifetime: Some(Duration::from_secs(1800)),
4006        };
4007
4008        let database_stats = DatabaseStats {
4009            task_count: 100,
4010            project_count: 20,
4011            area_count: 5,
4012        };
4013
4014        let status = ComprehensiveHealthStatus {
4015            overall_healthy: true,
4016            pool_health,
4017            pool_metrics,
4018            database_stats,
4019            timestamp: Utc::now(),
4020        };
4021
4022        assert!(status.overall_healthy);
4023        assert_eq!(status.database_stats.total_items(), 125);
4024    }
4025
4026    #[test]
4027    fn test_sqlite_optimizations_default_values() {
4028        let opts = SqliteOptimizations::default();
4029        assert!(opts.enable_wal_mode);
4030        assert!(opts.enable_foreign_keys);
4031        assert_eq!(opts.cache_size, -20000);
4032        assert_eq!(opts.temp_store, "MEMORY");
4033        assert_eq!(opts.mmap_size, 268_435_456);
4034        assert_eq!(opts.synchronous_mode, "NORMAL");
4035        assert_eq!(opts.journal_mode, "WAL");
4036    }
4037
4038    #[test]
4039    fn test_get_default_database_path_format() {
4040        let path = get_default_database_path();
4041        let path_str = path.to_string_lossy();
4042        assert!(path_str.contains("Things Database.thingsdatabase"));
4043        assert!(path_str.contains("main.sqlite"));
4044        assert!(path_str.contains("Library/Group Containers"));
4045    }
4046
4047    #[tokio::test]
4048    async fn test_database_new_with_config() {
4049        let temp_file = NamedTempFile::new().unwrap();
4050        let db_path = temp_file.path();
4051
4052        crate::test_utils::create_test_database(db_path)
4053            .await
4054            .unwrap();
4055
4056        let config = DatabasePoolConfig {
4057            max_connections: 5,
4058            min_connections: 1,
4059            connect_timeout: Duration::from_secs(10),
4060            idle_timeout: Duration::from_secs(300),
4061            max_lifetime: Duration::from_secs(900),
4062            test_before_acquire: true,
4063            sqlite_optimizations: SqliteOptimizations::default(),
4064        };
4065
4066        let database = ThingsDatabase::new_with_config(db_path, config)
4067            .await
4068            .unwrap();
4069        let pool = database.pool();
4070        assert!(!pool.is_closed());
4071    }
4072
4073    #[tokio::test]
4074    async fn test_database_error_handling_invalid_path() {
4075        // Test with non-existent database path
4076        let result = ThingsDatabase::new(Path::new("/non/existent/path.db")).await;
4077        assert!(result.is_err(), "Should fail with non-existent path");
4078    }
4079
4080    #[tokio::test]
4081    async fn test_database_get_stats() {
4082        let temp_file = NamedTempFile::new().unwrap();
4083        let db_path = temp_file.path();
4084
4085        crate::test_utils::create_test_database(db_path)
4086            .await
4087            .unwrap();
4088        let database = ThingsDatabase::new(db_path).await.unwrap();
4089
4090        let stats = database.get_stats().await.unwrap();
4091        assert!(stats.task_count > 0, "Should have test tasks");
4092        assert!(stats.area_count > 0, "Should have test areas");
4093        assert!(stats.total_items() > 0, "Should have total items");
4094    }
4095
4096    #[tokio::test]
4097    async fn test_database_comprehensive_health_check() {
4098        let temp_file = NamedTempFile::new().unwrap();
4099        let db_path = temp_file.path();
4100
4101        crate::test_utils::create_test_database(db_path)
4102            .await
4103            .unwrap();
4104        let database = ThingsDatabase::new(db_path).await.unwrap();
4105
4106        let health = database.comprehensive_health_check().await.unwrap();
4107        assert!(health.overall_healthy, "Database should be healthy");
4108        assert!(health.pool_health.is_healthy, "Pool should be healthy");
4109        assert!(
4110            health.pool_metrics.is_healthy,
4111            "Pool metrics should be healthy"
4112        );
4113    }
4114
4115    // ============================================================================
4116    // Date Conversion Tests - Edge Cases
4117    // ============================================================================
4118
4119    #[test]
4120    fn test_things_date_negative_returns_none() {
4121        // Negative values should return None
4122        assert_eq!(things_date_to_naive_date(-1), None);
4123        assert_eq!(things_date_to_naive_date(-100), None);
4124        assert_eq!(things_date_to_naive_date(i64::MIN), None);
4125    }
4126
4127    #[test]
4128    fn test_things_date_zero_returns_none() {
4129        // Zero should return None (no date set)
4130        assert_eq!(things_date_to_naive_date(0), None);
4131    }
4132
4133    #[test]
4134    fn test_things_date_boundary_2001() {
4135        use chrono::Datelike;
4136        // 1 second after 2001-01-01 00:00:00 should be 2001-01-01
4137        let result = things_date_to_naive_date(1);
4138        assert!(result.is_some());
4139
4140        let date = result.unwrap();
4141        assert_eq!(date.year(), 2001);
4142        assert_eq!(date.month(), 1);
4143        assert_eq!(date.day(), 1);
4144    }
4145
4146    #[test]
4147    fn test_things_date_one_day() {
4148        use chrono::Datelike;
4149        // 86400 seconds = 1 day (60 * 60 * 24), should be 2001-01-02
4150        let seconds_per_day = 86400i64;
4151        let result = things_date_to_naive_date(seconds_per_day);
4152        assert!(result.is_some());
4153
4154        let date = result.unwrap();
4155        assert_eq!(date.year(), 2001);
4156        assert_eq!(date.month(), 1);
4157        assert_eq!(date.day(), 2);
4158    }
4159
4160    #[test]
4161    fn test_things_date_one_year() {
4162        use chrono::Datelike;
4163        // ~365 days should be around 2002-01-01 (365 days * 86400 seconds/day)
4164        let seconds_per_year = 365 * 86400i64;
4165        let result = things_date_to_naive_date(seconds_per_year);
4166        assert!(result.is_some());
4167
4168        let date = result.unwrap();
4169        assert_eq!(date.year(), 2002);
4170    }
4171
4172    #[test]
4173    fn test_things_date_current_era() {
4174        use chrono::Datelike;
4175        // Test a date in the current era (2024)
4176        // Days from 2001-01-01 to 2024-01-01 = ~8401 days
4177        // Calculation: (2024-2001) * 365 + leap days (2004, 2008, 2012, 2016, 2020) = 23 * 365 + 5 = 8400
4178        let days_to_2024 = 8401i64;
4179        let seconds_to_2024 = days_to_2024 * 86400;
4180
4181        let result = things_date_to_naive_date(seconds_to_2024);
4182        assert!(result.is_some());
4183
4184        let date = result.unwrap();
4185        assert_eq!(date.year(), 2024);
4186    }
4187
4188    #[test]
4189    fn test_things_date_leap_year() {
4190        use chrono::{Datelike, TimeZone, Utc};
4191        // Test Feb 29, 2004 (leap year)
4192        // Days from 2001-01-01 to 2004-02-29
4193        let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4194        let target_date = Utc.with_ymd_and_hms(2004, 2, 29, 0, 0, 0).single().unwrap();
4195        let seconds_diff = (target_date - base_date).num_seconds();
4196
4197        let result = things_date_to_naive_date(seconds_diff);
4198        assert!(result.is_some());
4199
4200        let date = result.unwrap();
4201        assert_eq!(date.year(), 2004);
4202        assert_eq!(date.month(), 2);
4203        assert_eq!(date.day(), 29);
4204    }
4205
4206    // ============================================================================
4207    // UUID Conversion Tests
4208    // ============================================================================
4209
4210    #[test]
4211    fn test_uuid_conversion_consistency() {
4212        // Same input should always produce same UUID
4213        let input = "ABC123";
4214        let uuid1 = things_uuid_to_uuid(input);
4215        let uuid2 = things_uuid_to_uuid(input);
4216
4217        assert_eq!(uuid1, uuid2);
4218    }
4219
4220    #[test]
4221    fn test_uuid_conversion_uniqueness() {
4222        // Different inputs should produce different UUIDs
4223        let uuid1 = things_uuid_to_uuid("ABC123");
4224        let uuid2 = things_uuid_to_uuid("ABC124");
4225        let uuid3 = things_uuid_to_uuid("XYZ789");
4226
4227        assert_ne!(uuid1, uuid2);
4228        assert_ne!(uuid1, uuid3);
4229        assert_ne!(uuid2, uuid3);
4230    }
4231
4232    #[test]
4233    fn test_uuid_conversion_empty_string() {
4234        // Empty string should still produce a valid UUID
4235        let uuid = things_uuid_to_uuid("");
4236        assert!(!uuid.to_string().is_empty());
4237    }
4238
4239    #[test]
4240    fn test_uuid_conversion_special_characters() {
4241        // Special characters should be handled
4242        let uuid1 = things_uuid_to_uuid("test-with-dashes");
4243        let uuid2 = things_uuid_to_uuid("test_with_underscores");
4244        let uuid3 = things_uuid_to_uuid("test.with.dots");
4245
4246        // All should be valid and different
4247        assert_ne!(uuid1, uuid2);
4248        assert_ne!(uuid1, uuid3);
4249        assert_ne!(uuid2, uuid3);
4250    }
4251
4252    // ============================================================================
4253    // Timestamp Conversion Tests
4254    // ============================================================================
4255
4256    #[test]
4257    fn test_safe_timestamp_convert_normal_values() {
4258        // Normal timestamp values should convert correctly
4259        let ts = 1_700_000_000.0; // Around 2023
4260        let result = safe_timestamp_convert(ts);
4261        assert_eq!(result, 1_700_000_000);
4262    }
4263
4264    #[test]
4265    fn test_safe_timestamp_convert_zero() {
4266        // Zero should return zero
4267        assert_eq!(safe_timestamp_convert(0.0), 0);
4268    }
4269
4270    #[test]
4271    fn test_safe_timestamp_convert_negative() {
4272        // Negative values should return zero (safe fallback)
4273        assert_eq!(safe_timestamp_convert(-1.0), 0);
4274        assert_eq!(safe_timestamp_convert(-1000.0), 0);
4275    }
4276
4277    #[test]
4278    fn test_safe_timestamp_convert_infinity() {
4279        // Infinity should return zero (safe fallback)
4280        assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
4281        assert_eq!(safe_timestamp_convert(f64::NEG_INFINITY), 0);
4282    }
4283
4284    #[test]
4285    fn test_safe_timestamp_convert_nan() {
4286        // NaN should return zero (safe fallback)
4287        assert_eq!(safe_timestamp_convert(f64::NAN), 0);
4288    }
4289
4290    #[test]
4291    fn test_date_roundtrip_known_dates() {
4292        use chrono::{Datelike, TimeZone, Utc};
4293        // Test roundtrip conversion for known dates
4294        // Note: Starting from 2001-01-02 because 2001-01-01 is the base date (0 seconds)
4295        // and things_date_to_naive_date returns None for values <= 0
4296        let test_cases = vec![
4297            (2001, 1, 2), // Start from day 2 since day 1 is the base (0 seconds)
4298            (2010, 6, 15),
4299            (2020, 12, 31),
4300            (2024, 2, 29), // Leap year
4301            (2025, 7, 4),
4302        ];
4303
4304        for (year, month, day) in test_cases {
4305            let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
4306            let target_date = Utc
4307                .with_ymd_and_hms(year, month, day, 0, 0, 0)
4308                .single()
4309                .unwrap();
4310            let seconds = (target_date - base_date).num_seconds();
4311
4312            let converted = things_date_to_naive_date(seconds);
4313            assert!(
4314                converted.is_some(),
4315                "Failed to convert {}-{:02}-{:02}",
4316                year,
4317                month,
4318                day
4319            );
4320
4321            let result_date = converted.unwrap();
4322            assert_eq!(
4323                result_date.year(),
4324                year,
4325                "Year mismatch for {}-{:02}-{:02}",
4326                year,
4327                month,
4328                day
4329            );
4330            assert_eq!(
4331                result_date.month(),
4332                month,
4333                "Month mismatch for {}-{:02}-{:02}",
4334                year,
4335                month,
4336                day
4337            );
4338            assert_eq!(
4339                result_date.day(),
4340                day,
4341                "Day mismatch for {}-{:02}-{:02}",
4342                year,
4343                month,
4344                day
4345            );
4346        }
4347    }
4348}