//! Vault pruner for automatically removing expired trash items.
//!
//! This module handles the automatic cleanup of items that have been in the trash
//! (DeletedAt set) for longer than the retention period (default 30 days).
//! It generates SQL statements to permanently delete (IsDeleted = true) these items
//! along with their related entities.

use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::error::VaultResult;
use crate::vault_merge::SqlStatement;

/// A record is a map of column names to JSON values.
pub type Record = HashMap<String, serde_json::Value>;

/// Data for a single table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableData {
    /// Table name
    pub name: String,
    /// All records in this table
    pub records: Vec<Record>,
}

/// Input for the prune operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PruneInput {
    /// Tables from the local database (at minimum, Items table is required)
    pub tables: Vec<TableData>,
    /// Current time in ISO 8601 format with UTC timezone.
    ///
    /// **Required format**: `YYYY-MM-DDTHH:MM:SS.sssZ`
    ///
    /// Examples:
    /// - `"2024-01-15T10:30:00.000Z"`
    /// - `"2025-12-24T21:33:30.674Z"`
    ///
    /// Callers should use:
    /// - JavaScript: `new Date().toISOString()`
    /// - C#: `DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")`
    /// - Swift: `ISO8601DateFormatter().string(from: Date())`
    /// - Kotlin: `Instant.now().toString()` or `SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date())`
    pub current_time: String,
    /// Retention period in days (default: 30)
    #[serde(default = "default_retention_days")]
    pub retention_days: u32,
}

fn default_retention_days() -> u32 {
    30
}

/// Statistics about what was pruned.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct PruneStats {
    /// Number of items permanently deleted
    pub items_pruned: u32,
    /// Number of field values permanently deleted
    pub field_values_pruned: u32,
    /// Number of attachments permanently deleted
    pub attachments_pruned: u32,
    /// Number of TOTP codes permanently deleted
    pub totp_codes_pruned: u32,
    /// Number of passkeys permanently deleted
    pub passkeys_pruned: u32,
    /// Number of orphan logos soft-deleted (no remaining active item references them)
    #[serde(default)]
    pub logos_pruned: u32,
    /// Number of tombstoned attachments whose blob bytes were cleared.
    #[serde(default)]
    pub attachment_blobs_cleared: u32,
    /// Number of tombstoned logos whose FileData bytes were cleared.
    #[serde(default)]
    pub logo_blobs_cleared: u32,
}

/// Output of the prune operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PruneOutput {
    /// Whether the prune was successful
    pub success: bool,
    /// SQL statements to execute on the local database (in order)
    pub statements: Vec<SqlStatement>,
    /// Statistics about what was pruned
    pub stats: PruneStats,
}

/// A per-table SELECT query for building `PruneInput`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct PruneTableQuery {
    /// Table name
    pub name: String,
    /// SELECT query reading only the columns the pruner inspects
    pub query: String,
}

/// Get the per-table SELECT queries clients should run to build `PruneInput`.
///
/// Only the columns the pruner inspects are selected; blob columns are reduced
/// to a 1-byte presence marker (via `substr`) to avoid serializing large binary
/// data to JSON on every prune.
pub fn get_prune_table_queries() -> Vec<PruneTableQuery> {
    [
        ("Items", "SELECT Id, IsDeleted, DeletedAt, LogoId FROM Items"),
        ("FieldValues", "SELECT ItemId, IsDeleted FROM FieldValues"),
        ("Attachments", "SELECT Id, ItemId, IsDeleted, substr(Blob, 1, 1) AS Blob FROM Attachments"),
        ("TotpCodes", "SELECT ItemId, IsDeleted FROM TotpCodes"),
        ("Passkeys", "SELECT ItemId, IsDeleted FROM Passkeys"),
        ("Logos", "SELECT Id, IsDeleted, substr(FileData, 1, 1) AS FileData FROM Logos"),
    ]
    .iter()
    .map(|(name, query)| PruneTableQuery {
        name: (*name).to_string(),
        query: (*query).to_string(),
    })
    .collect()
}

/// Main entry point: prune expired items from trash.
///
/// This function finds all Items with DeletedAt set that are older than
/// retention_days and generates SQL statements to mark them and their
/// related entities as permanently deleted (IsDeleted = true).
///
/// # Arguments
/// * `input` - PruneInput containing table data, retention period, and current time
///
/// # Returns
/// PruneOutput with SQL statements to execute on local database
pub fn prune_vault(input: PruneInput) -> VaultResult<PruneOutput> {
    let mut stats = PruneStats::default();
    let mut statements: Vec<SqlStatement> = Vec::new();

    // Parse current time from input (required from caller)
    let now = parse_datetime(&input.current_time)
        .ok_or_else(|| crate::error::VaultError::General(
            format!("Invalid current_time format: {}", input.current_time)
        ))?;
    let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

    // Calculate cutoff date for trash retention
    let cutoff_date = now - Duration::days(input.retention_days as i64);

    // Items table is required for both passes (trash purge + logo orphan check).
    let items_table = match input.tables.iter().find(|t| t.name == "Items") {
        Some(t) => t,
        None => {
            return Ok(PruneOutput {
                success: true,
                statements,
                stats,
            });
        }
    };
    let items = &items_table.records;

    // Pass 1 — find items in trash older than retention period.
    let mut expired_item_ids: Vec<String> = Vec::new();

    for item in items {
        // Skip if already permanently deleted
        if let Some(is_deleted) = item.get("IsDeleted") {
            if is_deleted.as_i64() == Some(1) || is_deleted.as_bool() == Some(true) {
                continue;
            }
        }

        // Check if item is in trash (DeletedAt is set and not null)
        if let Some(deleted_at) = item.get("DeletedAt") {
            if deleted_at.is_null() {
                continue;
            }

            if let Some(deleted_at_str) = deleted_at.as_str() {
                if let Some(deleted_date) = parse_datetime(deleted_at_str) {
                    if deleted_date < cutoff_date {
                        if let Some(id) = item.get("Id").and_then(|v| v.as_str()) {
                            expired_item_ids.push(id.to_string());
                        }
                    }
                }
            }
        }
    }

    // Pass 1 — generate SQL for expired items + related entities.
    for item_id in &expired_item_ids {
        // Mark item as permanently deleted
        statements.push(SqlStatement {
            sql: "UPDATE Items SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?".to_string(),
            params: vec![
                serde_json::json!(now_str),
                serde_json::json!(item_id),
            ],
        });
        stats.items_pruned += 1;

        // Mark related FieldValues as deleted
        if let Some(field_values_table) = input.tables.iter().find(|t| t.name == "FieldValues") {
            let related_count = count_related_records(&field_values_table.records, "ItemId", item_id);
            if related_count > 0 {
                statements.push(SqlStatement {
                    sql: "UPDATE FieldValues SET IsDeleted = 1, UpdatedAt = ? WHERE ItemId = ? AND IsDeleted = 0".to_string(),
                    params: vec![
                        serde_json::json!(now_str),
                        serde_json::json!(item_id),
                    ],
                });
                stats.field_values_pruned += related_count;
            }
        }

        // Mark related Attachments as deleted and drop their blob bytes. Leaves the 
        // column non-null while reclaiming the storage on the next save.
        if let Some(attachments_table) = input.tables.iter().find(|t| t.name == "Attachments") {
            let related_count = count_related_records(&attachments_table.records, "ItemId", item_id);
            if related_count > 0 {
                statements.push(SqlStatement {
                    sql: "UPDATE Attachments SET IsDeleted = 1, Blob = X'', UpdatedAt = ? WHERE ItemId = ? AND IsDeleted = 0".to_string(),
                    params: vec![
                        serde_json::json!(now_str),
                        serde_json::json!(item_id),
                    ],
                });
                stats.attachments_pruned += related_count;
            }
        }

        // Mark related TotpCodes as deleted
        if let Some(totp_table) = input.tables.iter().find(|t| t.name == "TotpCodes") {
            let related_count = count_related_records(&totp_table.records, "ItemId", item_id);
            if related_count > 0 {
                statements.push(SqlStatement {
                    sql: "UPDATE TotpCodes SET IsDeleted = 1, UpdatedAt = ? WHERE ItemId = ? AND IsDeleted = 0".to_string(),
                    params: vec![
                        serde_json::json!(now_str),
                        serde_json::json!(item_id),
                    ],
                });
                stats.totp_codes_pruned += related_count;
            }
        }

        // Mark related Passkeys as deleted
        if let Some(passkeys_table) = input.tables.iter().find(|t| t.name == "Passkeys") {
            let related_count = count_related_records(&passkeys_table.records, "ItemId", item_id);
            if related_count > 0 {
                statements.push(SqlStatement {
                    sql: "UPDATE Passkeys SET IsDeleted = 1, UpdatedAt = ? WHERE ItemId = ? AND IsDeleted = 0".to_string(),
                    params: vec![
                        serde_json::json!(now_str),
                        serde_json::json!(item_id),
                    ],
                });
                stats.passkeys_pruned += related_count;
            }
        }
    }

    // Pass 2 — orphan logo cleanup. A Logo is orphan when no Item with
    // IsDeleted=0 references it. Items being purged in Pass 1 are treated
    // as effectively deleted so logos they referenced can be reclaimed in
    // the same call.
    if let Some(logos_table) = input.tables.iter().find(|t| t.name == "Logos") {
        let expired_set: std::collections::HashSet<&str> =
            expired_item_ids.iter().map(|s| s.as_str()).collect();

        let mut referenced_logo_ids: std::collections::HashSet<String> =
            std::collections::HashSet::new();
        for item in items {
            let is_deleted = item.get("IsDeleted")
                .map(|v| v.as_i64() == Some(1) || v.as_bool() == Some(true))
                .unwrap_or(false);
            if is_deleted {
                continue;
            }
            let item_id = item.get("Id").and_then(|v| v.as_str()).unwrap_or("");
            if expired_set.contains(item_id) {
                continue;
            }
            if let Some(logo_id) = item.get("LogoId").and_then(|v| v.as_str()) {
                referenced_logo_ids.insert(logo_id.to_string());
            }
        }

        for logo in &logos_table.records {
            let is_deleted = logo.get("IsDeleted")
                .map(|v| v.as_i64() == Some(1) || v.as_bool() == Some(true))
                .unwrap_or(false);
            if is_deleted {
                continue;
            }

            if let Some(logo_id) = logo.get("Id").and_then(|v| v.as_str()) {
                if !referenced_logo_ids.contains(logo_id) {
                    statements.push(SqlStatement {
                        sql: "UPDATE Logos SET IsDeleted = 1, FileData = X'', UpdatedAt = ? WHERE Id = ?".to_string(),
                        params: vec![
                            serde_json::json!(now_str),
                            serde_json::json!(logo_id),
                        ],
                    });
                    stats.logos_pruned += 1;
                }
            }
        }
    }

    // Pass 3 — sweep tombstoned attachments that still carry blob bytes.
    // Older client versions could leave attachment with IsDeleted=1 but 
    // a non-empty Blob, which inflates  the encrypted vault for no reason.
    // This pass empties those blobs in place. The attachments tombstoned by 
    // Pass 1 in this same call are already cleared there, so this pass only 
    // catches historical leftovers.
    if let Some(attachments_table) = input.tables.iter().find(|t| t.name == "Attachments") {
        for attachment in &attachments_table.records {
            let is_deleted = attachment.get("IsDeleted")
                .map(|v| v.as_i64() == Some(1) || v.as_bool() == Some(true))
                .unwrap_or(false);
            if !is_deleted {
                continue;
            }

            if !attachment_has_blob_bytes(attachment) {
                continue;
            }

            if let Some(attachment_id) = attachment.get("Id").and_then(|v| v.as_str()) {
                statements.push(SqlStatement {
                    sql: "UPDATE Attachments SET Blob = X'', UpdatedAt = ? WHERE Id = ?".to_string(),
                    params: vec![
                        serde_json::json!(now_str),
                        serde_json::json!(attachment_id),
                    ],
                });
                stats.attachment_blobs_cleared += 1;
            }
        }
    }

    // Pass 4 — sweep tombstoned logos that still carry FileData bytes. Logos that have
    // been soft-deleted but still have FileData bytes take up space for no reason.
    // This behaviour could have occurred in older clients (before 0.29.x).
    if let Some(logos_table) = input.tables.iter().find(|t| t.name == "Logos") {
        for logo in &logos_table.records {
            let is_deleted = logo.get("IsDeleted")
                .map(|v| v.as_i64() == Some(1) || v.as_bool() == Some(true))
                .unwrap_or(false);
            if !is_deleted {
                continue;
            }

            if !logo_has_file_data_bytes(logo) {
                continue;
            }

            if let Some(logo_id) = logo.get("Id").and_then(|v| v.as_str()) {
                statements.push(SqlStatement {
                    sql: "UPDATE Logos SET FileData = X'', UpdatedAt = ? WHERE Id = ?".to_string(),
                    params: vec![
                        serde_json::json!(now_str),
                        serde_json::json!(logo_id),
                    ],
                });
                stats.logo_blobs_cleared += 1;
            }
        }
    }

    Ok(PruneOutput {
        success: true,
        statements,
        stats,
    })
}

/// True if the attachment's Blob field is present and non-empty.
fn attachment_has_blob_bytes(attachment: &Record) -> bool {
    value_has_bytes(attachment.get("Blob"))
}

/// True if the logo's FileData field is present and non-empty.
fn logo_has_file_data_bytes(logo: &Record) -> bool {
    value_has_bytes(logo.get("FileData"))
}

/// True if a JSON value represents non-empty blob bytes.
fn value_has_bytes(value: Option<&serde_json::Value>) -> bool {
    match value {
        None => false,
        Some(serde_json::Value::Null) => false,
        Some(serde_json::Value::String(s)) => !s.is_empty(),
        Some(serde_json::Value::Array(a)) => !a.is_empty(),
        Some(serde_json::Value::Object(o)) => !o.is_empty(),
        Some(_) => true,
    }
}

/// Prune vault using JSON strings.
/// Convenience function for FFI.
pub fn prune_vault_json(input_json: &str) -> VaultResult<String> {
    let input: PruneInput = serde_json::from_str(input_json)?;
    let output = prune_vault(input)?;
    let output_json = serde_json::to_string(&output)?;
    Ok(output_json)
}

/// Parse a datetime string in various formats.
/// Handles both RFC3339 format (2025-12-11T06:50:10.674Z) and
/// SQLite format (2025-12-11 06:50:10.674).
fn parse_datetime(s: &str) -> Option<DateTime<Utc>> {
    // Try RFC3339 first
    DateTime::parse_from_rfc3339(s)
        .map(|dt| dt.with_timezone(&Utc))
        .ok()
        .or_else(|| {
            // Try SQLite format: "YYYY-MM-DD HH:MM:SS.mmm"
            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f")
                .ok()
                .map(|naive| naive.and_utc())
        })
}

/// Count related records that match a foreign key value and are not already deleted.
fn count_related_records(records: &[Record], fk_column: &str, fk_value: &str) -> u32 {
    records.iter().filter(|r| {
        // Check if FK matches
        let fk_matches = r.get(fk_column)
            .and_then(|v| v.as_str())
            .map(|v| v == fk_value)
            .unwrap_or(false);

        // Check if not already deleted
        let not_deleted = match r.get("IsDeleted") {
            Some(v) => v.as_i64() != Some(1) && v.as_bool() != Some(true),
            None => true,
        };

        fk_matches && not_deleted
    }).count() as u32
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_item_record(id: &str, deleted_at: Option<&str>, is_deleted: bool) -> Record {
        let mut record = HashMap::new();
        record.insert("Id".to_string(), serde_json::json!(id));
        record.insert("UpdatedAt".to_string(), serde_json::json!("2024-01-01T00:00:00Z"));
        record.insert("IsDeleted".to_string(), serde_json::json!(if is_deleted { 1 } else { 0 }));
        if let Some(dt) = deleted_at {
            record.insert("DeletedAt".to_string(), serde_json::json!(dt));
        } else {
            record.insert("DeletedAt".to_string(), serde_json::Value::Null);
        }
        record
    }

    fn make_field_value_record(id: &str, item_id: &str, is_deleted: bool) -> Record {
        let mut record = HashMap::new();
        record.insert("Id".to_string(), serde_json::json!(id));
        record.insert("ItemId".to_string(), serde_json::json!(item_id));
        record.insert("UpdatedAt".to_string(), serde_json::json!("2024-01-01T00:00:00Z"));
        record.insert("IsDeleted".to_string(), serde_json::json!(if is_deleted { 1 } else { 0 }));
        record
    }

    fn make_attachment_record(
        id: &str,
        item_id: &str,
        is_deleted: bool,
        blob: serde_json::Value,
    ) -> Record {
        let mut record = HashMap::new();
        record.insert("Id".to_string(), serde_json::json!(id));
        record.insert("ItemId".to_string(), serde_json::json!(item_id));
        record.insert("UpdatedAt".to_string(), serde_json::json!("2024-01-01T00:00:00Z"));
        record.insert("IsDeleted".to_string(), serde_json::json!(if is_deleted { 1 } else { 0 }));
        record.insert("Blob".to_string(), blob);
        record
    }

    fn make_item_with_logo(
        id: &str,
        logo_id: Option<&str>,
        deleted_at: Option<&str>,
        is_deleted: bool,
    ) -> Record {
        let mut record = make_item_record(id, deleted_at, is_deleted);
        match logo_id {
            Some(lid) => record.insert("LogoId".to_string(), serde_json::json!(lid)),
            None => record.insert("LogoId".to_string(), serde_json::Value::Null),
        };
        record
    }

    fn make_logo_record(id: &str, is_deleted: bool) -> Record {
        let mut record = HashMap::new();
        record.insert("Id".to_string(), serde_json::json!(id));
        record.insert("Source".to_string(), serde_json::json!("example.com"));
        record.insert("UpdatedAt".to_string(), serde_json::json!("2024-01-01T00:00:00Z"));
        record.insert("IsDeleted".to_string(), serde_json::json!(if is_deleted { 1 } else { 0 }));
        record
    }

    fn make_logo_record_with_blob(id: &str, is_deleted: bool, blob: serde_json::Value) -> Record {
        let mut record = make_logo_record(id, is_deleted);
        record.insert("FileData".to_string(), blob);
        record
    }

    #[test]
    fn test_prune_expired_items() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // Create an item deleted 60 days ago
        let old_date = (now - Duration::days(60)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_record("item-1", Some(&old_date), false)],
                },
                TableData {
                    name: "FieldValues".to_string(),
                    records: vec![make_field_value_record("fv-1", "item-1", false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.items_pruned, 1);
        assert_eq!(output.stats.field_values_pruned, 1);
        assert!(output.statements.len() >= 2); // At least item + field value updates
    }

    #[test]
    fn test_no_prune_recent_items() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // Create an item deleted 10 days ago (within retention)
        let recent_date = (now - Duration::days(10)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_record("item-1", Some(&recent_date), false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.items_pruned, 0);
        assert!(output.statements.is_empty());
    }

    #[test]
    fn test_no_prune_active_items() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // Create an item that's not in trash (DeletedAt is null)
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_record("item-1", None, false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.items_pruned, 0);
        assert!(output.statements.is_empty());
    }

    #[test]
    fn test_no_prune_already_deleted() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // Create an item that's already permanently deleted
        let old_date = (now - Duration::days(60)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_record("item-1", Some(&old_date), true)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.items_pruned, 0);
        assert!(output.statements.is_empty());
    }

    #[test]
    fn test_prune_json_api() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let old_date = (now - Duration::days(60)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

        let input_json = format!(r#"{{
            "tables": [{{
                "name": "Items",
                "records": [{{
                    "Id": "item-1",
                    "UpdatedAt": "2024-01-01T00:00:00Z",
                    "IsDeleted": 0,
                    "DeletedAt": "{}"
                }}]
            }}],
            "retention_days": 30,
            "current_time": "{}"
        }}"#, old_date, now_str);

        let output_json = prune_vault_json(&input_json).unwrap();
        let output: PruneOutput = serde_json::from_str(&output_json).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.items_pruned, 1);
    }

    fn logo_update_count(output: &PruneOutput) -> usize {
        output.statements.iter()
            .filter(|s| s.sql.starts_with("UPDATE Logos"))
            .count()
    }

    #[test]
    fn test_orphan_logo_with_no_referencing_items_is_pruned() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record("logo-orphan", false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.logos_pruned, 1);
        assert_eq!(logo_update_count(&output), 1);
    }

    #[test]
    fn test_logo_referenced_by_active_item_is_kept() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_with_logo("item-1", Some("logo-1"), None, false)],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record("logo-1", false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert!(output.success);
        assert_eq!(output.stats.logos_pruned, 0);
        assert_eq!(logo_update_count(&output), 0);
    }

    #[test]
    fn test_logo_referenced_only_by_tombstoned_item_is_pruned() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // The tombstoned item (IsDeleted=1) still has LogoId set; the logo
        // should be considered orphan since no active item references it.
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_with_logo("item-1", Some("logo-1"), None, true)],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record("logo-1", false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.logos_pruned, 1);
    }

    #[test]
    fn test_logo_referenced_only_by_item_being_purged_is_pruned() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // Item is in trash older than retention — Pass 1 will tombstone it,
        // Pass 2 should reclaim its logo in the same call.
        let old_date = (now - Duration::days(60)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_with_logo("item-1", Some("logo-1"), Some(&old_date), false)],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record("logo-1", false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.items_pruned, 1);
        assert_eq!(output.stats.logos_pruned, 1);
    }

    #[test]
    fn test_logo_referenced_by_item_in_recent_trash_is_kept() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        // Item is in trash but within retention — could still be restored,
        // so its logo must be preserved.
        let recent_date = (now - Duration::days(10)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_with_logo("item-1", Some("logo-1"), Some(&recent_date), false)],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record("logo-1", false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.items_pruned, 0);
        assert_eq!(output.stats.logos_pruned, 0);
    }

    #[test]
    fn test_orphan_logo_pruning_emits_filedata_clear_in_same_statement() {
        // Pass 2 must clear FileData when it tombstones a logo, otherwise the
        // encrypted vault keeps the blob bytes even after the row is "deleted".
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record_with_blob("logo-orphan", false, serde_json::json!("aGVsbG8="))],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.logos_pruned, 1);
        assert!(output.statements.iter().any(|s| s.sql.contains("FileData = X''")));
    }

    #[test]
    fn test_tombstoned_logo_with_blob_bytes_is_swept() {
        // Pass 4 must catch historical logos that are IsDeleted=1 but still carry FileData
        // (e.g. tombstoned by an older client before the FileData=X'' fix landed).
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record_with_blob("logo-tombstoned", true, serde_json::json!("aGVsbG8="))],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.logo_blobs_cleared, 1);
        assert_eq!(output.stats.logos_pruned, 0);
    }

    #[test]
    fn test_tombstoned_logo_without_blob_is_not_touched() {
        // Logos already cleared shouldn't generate redundant updates.
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record_with_blob("logo-tombstoned", true, serde_json::Value::Null)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.logo_blobs_cleared, 0);
    }

    #[test]
    fn test_already_soft_deleted_logo_is_not_re_pruned() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record("logo-1", true)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.logos_pruned, 0);
    }

    #[test]
    fn test_logo_pruning_skipped_when_logos_table_absent() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_with_logo("item-1", Some("logo-1"), None, false)],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.logos_pruned, 0);
        assert_eq!(logo_update_count(&output), 0);
    }

    #[test]
    fn test_trash_purge_clears_attachment_blobs() {
        let now = Utc::now();
        let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let old_date = (now - Duration::days(60)).format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![make_item_record("item-1", Some(&old_date), false)],
                },
                TableData {
                    name: "Attachments".to_string(),
                    records: vec![make_attachment_record(
                        "att-1",
                        "item-1",
                        false,
                        serde_json::json!("aGVsbG8="),
                    )],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.items_pruned, 1);
        assert_eq!(output.stats.attachments_pruned, 1);
        // The trash-purge UPDATE for Attachments should now also clear the blob.
        let attachment_update = output.statements.iter()
            .find(|s| s.sql.starts_with("UPDATE Attachments"))
            .expect("expected an UPDATE Attachments statement");
        assert!(attachment_update.sql.contains("Blob = X''"),
            "attachment trash purge must zero the blob: {}", attachment_update.sql);
        // The pass-3 sweeper should NOT also fire for the same row in this call.
        assert_eq!(output.stats.attachment_blobs_cleared, 0);
    }

    #[test]
    fn test_sweeper_clears_blob_on_already_tombstoned_attachment() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Attachments".to_string(),
                    records: vec![make_attachment_record(
                        "att-old",
                        "item-1",
                        true,
                        serde_json::json!("aGVsbG8="),
                    )],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.attachment_blobs_cleared, 1);
        let stmt = output.statements.iter()
            .find(|s| s.sql.starts_with("UPDATE Attachments SET Blob = X''"))
            .expect("expected the sweeper UPDATE");
        // params: [updated_at, attachment_id]
        assert_eq!(stmt.params.len(), 2);
        assert_eq!(stmt.params[1], serde_json::json!("att-old"));
    }

    #[test]
    fn test_sweeper_skips_already_empty_blob() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Attachments".to_string(),
                    records: vec![
                        // Empty string (already cleared) — should be skipped.
                        make_attachment_record("att-empty-string", "item-1", true, serde_json::json!("")),
                        // Empty array form — should also be skipped.
                        make_attachment_record("att-empty-array", "item-1", true, serde_json::json!([])),
                        // Null Blob — should also be skipped.
                        make_attachment_record("att-null", "item-1", true, serde_json::Value::Null),
                    ],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.attachment_blobs_cleared, 0);
        assert!(output.statements.is_empty());
    }

    #[test]
    fn test_sweeper_skips_empty_uint8array_object_blob() {
        // An already-cleared blob must not generate a clear statement on every prune.
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Attachments".to_string(),
                    records: vec![make_attachment_record("att-empty-object", "item-1", true, serde_json::json!({}))],
                },
                TableData {
                    name: "Logos".to_string(),
                    records: vec![make_logo_record_with_blob("logo-empty-object", true, serde_json::json!({}))],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.attachment_blobs_cleared, 0);
        assert_eq!(output.stats.logo_blobs_cleared, 0);
        assert!(output.statements.is_empty());
    }

    #[test]
    fn test_sweeper_clears_nonempty_uint8array_object_blob() {
        // Non-empty blobs are serialized as {"0":104,...}.
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Attachments".to_string(),
                    records: vec![make_attachment_record("att-object", "item-1", true, serde_json::json!({"0": 104, "1": 105}))],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.attachment_blobs_cleared, 1);
    }

    #[test]
    fn test_sweeper_skips_active_attachment_with_blob() {
        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
        let input = PruneInput {
            tables: vec![
                TableData {
                    name: "Items".to_string(),
                    records: vec![],
                },
                TableData {
                    name: "Attachments".to_string(),
                    records: vec![make_attachment_record(
                        "att-active",
                        "item-1",
                        false,
                        serde_json::json!("aGVsbG8="),
                    )],
                },
            ],
            retention_days: 30,
            current_time: now_str,
        };

        let output = prune_vault(input).unwrap();

        assert_eq!(output.stats.attachment_blobs_cleared, 0);
        assert!(output.statements.is_empty());
    }
}
