//! Duplicate-file listing, grouped by content hash. Backs the GUI's
//! Duplicates tab; standalone and synchronous — callers run it on their
//! own worker thread.

use rusqlite::params;

use crate::db;

#[derive(Debug, Clone, PartialEq)]
pub struct DuplicateGroup {
    pub hash: Vec<u8>,
    pub count: i64,
    pub total_size: i64,
    /// Bytes reclaimable by deduplicating: `size × (count - 1)` — the
    /// group's sort key.
    pub redundant_size: i64,
    /// `(file_id, name, path, size, mtime)` per member, path-ordered.
    pub members: Vec<(i64, String, String, u64, i64)>,
}

/// Page through hash groups having more than one member, ordered by
/// reclaimable bytes (largest first). Rows with a NULL hash (never
/// hashed) and zero-size files (all trivially identical) are excluded.
pub fn find_duplicate_groups(
    db_path: &str,
    limit: u32,
    offset: u32,
) -> Result<Vec<DuplicateGroup>, String> {
    let conn = db::open_existing(db_path, false)?;
    let mut groups: Vec<DuplicateGroup> = Vec::new();
    {
        // SUM(size) - MAX(size) == size × (count - 1); members of a group
        // share a size because the hash covers it.
        let mut stmt = conn
            .prepare(
                "SELECT hash, COUNT(*) AS cnt, SUM(size), SUM(size) - MAX(size) AS redundant \
                 FROM files \
                 WHERE hash IS NOT NULL AND size > 0 \
                 GROUP BY hash HAVING cnt > 1 \
                 ORDER BY redundant DESC, hash \
                 LIMIT ?1 OFFSET ?2",
            )
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map(params![limit, offset], |r| {
                Ok(DuplicateGroup {
                    hash: r.get(0)?,
                    count: r.get(1)?,
                    total_size: r.get(2)?,
                    redundant_size: r.get(3)?,
                    members: Vec::new(),
                })
            })
            .map_err(|e| e.to_string())?;
        for g in rows {
            groups.push(g.map_err(|e| e.to_string())?);
        }
    }

    // `ORDER BY parent, name` rather than by path: it is the stored key, so
    // the sort is free, and it groups a directory's copies together — which is
    // what the tab shows anyway.
    let mut member_stmt = conn
        .prepare(
            "SELECT id, name, parent, size, mtime FROM files \
             WHERE hash = ?1 ORDER BY parent, name",
        )
        .map_err(|e| e.to_string())?;
    for group in &mut groups {
        let rows = member_stmt
            .query_map(params![group.hash], |r| {
                let name: String = r.get(1)?;
                let parent: String = r.get(2)?;
                let path = format!("{}{}", parent, name);
                Ok((
                    r.get::<_, i64>(0)?,
                    name,
                    path,
                    r.get::<_, i64>(3)?.max(0) as u64,
                    r.get::<_, i64>(4)?,
                ))
            })
            .map_err(|e| e.to_string())?;
        for m in rows {
            group.members.push(m.map_err(|e| e.to_string())?);
        }
    }

    Ok(groups)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::open_or_recreate;
    use crate::db::repo::{insert_file, NewFile};
    use crate::mime::FileType;

    fn seed_db() -> std::path::PathBuf {
        let p = crate::testutil::scratch_dir("dups").join("index.sqlite");
        let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
        let tx = conn.transaction().unwrap();
        let add = |name: &str, size: u64, hash: Option<&[u8]>| {
            insert_file(
                &tx,
                &NewFile {
                    name,
                    parent: "/d/",
                    size,
                    mtime: 1_700_000_000,
                    mime: None,
                    ftype: FileType::TEXT,
                    hash,
                    needs_content: false,
                },
            )
            .unwrap()
            .expect("unique path");
        };
        // Triple group of small files: redundant = 10 × 2 = 20.
        add("a1.txt", 10, Some(b"AAA"));
        add("a2.txt", 10, Some(b"AAA"));
        add("a3.txt", 10, Some(b"AAA"));
        // Pair of large files: redundant = 100 × 1 = 100 — sorts first
        // despite the smaller member count.
        add("b1.txt", 100, Some(b"BBB"));
        add("b2.txt", 100, Some(b"BBB"));
        // Singletons and NULL hashes never appear.
        add("c.txt", 30, Some(b"CCC"));
        add("n1.txt", 40, None);
        add("n2.txt", 40, None);
        // Zero-size files are trivially identical — excluded outright.
        add("z1.txt", 0, Some(b"ZZZ"));
        add("z2.txt", 0, Some(b"ZZZ"));
        tx.commit().unwrap();
        drop(conn);
        p
    }

    #[test]
    fn groups_ordered_by_redundant_size_zero_size_excluded() {
        let p = seed_db();
        let groups = find_duplicate_groups(p.to_str().unwrap(), 10, 0).unwrap();
        assert_eq!(
            groups.len(),
            2,
            "singletons, NULL hashes, and zero-size groups excluded"
        );
        // Reclaimable bytes beat member count for ordering.
        assert_eq!(groups[0].count, 2);
        assert_eq!(groups[0].total_size, 200);
        assert_eq!(groups[0].redundant_size, 100);
        assert_eq!(groups[1].count, 3);
        assert_eq!(groups[1].redundant_size, 20);
        assert_eq!(groups[1].members.len(), 3);
        assert_eq!(groups[1].members[0].1, "a1.txt", "members path-ordered");
        std::fs::remove_file(&p).ok();
    }

    #[test]
    fn pagination() {
        let p = seed_db();
        let page1 = find_duplicate_groups(p.to_str().unwrap(), 1, 0).unwrap();
        let page2 = find_duplicate_groups(p.to_str().unwrap(), 1, 1).unwrap();
        assert_eq!(page1.len(), 1);
        assert_eq!(page2.len(), 1);
        assert_ne!(page1[0].hash, page2[0].hash);
        let page3 = find_duplicate_groups(p.to_str().unwrap(), 1, 2).unwrap();
        assert!(page3.is_empty());
        std::fs::remove_file(&p).ok();
    }
}
