//! Inode bookkeeping for the mounted filesystem view.

use fuser::FUSE_ROOT_ID;
use std::collections::HashMap;
use std::hash::Hash;

/// A tracked inode and the node metadata it represents.
#[derive(Clone, Debug)]
pub(crate) struct InodeRecord<K, T> {
    /// The inode number handed to the kernel.
    pub(crate) ino: u64,
    /// The generation associated with this inode number.
    pub(crate) generation: u64,
    /// The current kernel lookup count for this inode.
    pub(crate) lookup_count: u64,
    /// The logical identity used to find this inode again.
    pub(crate) key: K,
    /// The filesystem node described by this inode.
    pub(crate) node: T,
}

/// A monotonic inode allocator with reverse lookup by node identity.
#[derive(Debug)]
pub(crate) struct InodeTable<K, T> {
    next_ino: u64,
    records: HashMap<u64, InodeRecord<K, T>>,
    by_key: HashMap<K, u64>,
}

impl<K, T> InodeTable<K, T>
where
    K: Clone + Eq + Hash,
    T: Clone,
{
    /// Create a table with the root node pre-installed at `FUSE_ROOT_ID`.
    pub(crate) fn new(root_key: K, root_node: T) -> Self {
        let root_record = InodeRecord {
            ino: FUSE_ROOT_ID,
            generation: 0,
            lookup_count: 1,
            key: root_key.clone(),
            node: root_node,
        };

        let mut records = HashMap::new();
        records.insert(FUSE_ROOT_ID, root_record);

        let mut by_key = HashMap::new();
        by_key.insert(root_key, FUSE_ROOT_ID);

        Self {
            next_ino: FUSE_ROOT_ID + 1,
            records,
            by_key,
        }
    }

    /// Return the tracked record for `ino`, if present.
    pub(crate) fn get(&self, ino: u64) -> Option<&InodeRecord<K, T>> {
        self.records.get(&ino)
    }

    /// Return the number of tracked inode records.
    pub(crate) fn len(&self) -> usize {
        self.records.len()
    }

    /// Remember a successful lookup, allocating a new inode if needed.
    pub(crate) fn remember_lookup<F>(&mut self, key: K, create: F) -> Result<InodeRecord<K, T>, i32>
    where
        F: FnOnce() -> Result<T, i32>,
    {
        if let Some(existing) = self.by_key.get(&key).copied() {
            if let Some(record) = self.records.get_mut(&existing) {
                record.lookup_count = record.lookup_count.saturating_add(1);
                return Ok(record.clone());
            }
        }

        let node = create()?;
        let ino = self.next_ino;
        self.next_ino = self.next_ino.saturating_add(1);
        let record = InodeRecord {
            ino,
            generation: 1,
            lookup_count: 1,
            key: key.clone(),
            node,
        };

        self.by_key.insert(key, ino);
        self.records.insert(ino, record.clone());
        Ok(record)
    }

    /// Remember a node observed through `readdir` without incrementing lookup count.
    pub(crate) fn remember_seen<F>(&mut self, key: K, create: F) -> Result<InodeRecord<K, T>, i32>
    where
        F: FnOnce() -> Result<T, i32>,
    {
        if let Some(existing) = self.by_key.get(&key).copied() {
            if let Some(record) = self.records.get(&existing) {
                return Ok(record.clone());
            }
        }

        let node = create()?;
        let ino = self.next_ino;
        self.next_ino = self.next_ino.saturating_add(1);
        let record = InodeRecord {
            ino,
            generation: 1,
            lookup_count: 0,
            key: key.clone(),
            node,
        };

        self.by_key.insert(key, ino);
        self.records.insert(ino, record.clone());
        Ok(record)
    }

    /// Decrement the kernel lookup count for an inode.
    pub(crate) fn forget(&mut self, ino: u64, nlookup: u64) {
        if ino == FUSE_ROOT_ID {
            return;
        }

        if let Some(record) = self.records.get_mut(&ino) {
            record.lookup_count = record.lookup_count.saturating_sub(nlookup);
        }
        self.evict_unreferenced();
    }

    /// Drop every non-root inode whose lookup count has reached zero.
    pub(crate) fn evict_unreferenced(&mut self) {
        let doomed: Vec<(u64, K)> = self
            .records
            .iter()
            .filter(|(ino, record)| **ino != FUSE_ROOT_ID && record.lookup_count == 0)
            .map(|(ino, record)| (*ino, record.key.clone()))
            .collect();

        for (ino, key) in doomed {
            self.records.remove(&ino);
            self.by_key.remove(&key);
        }
    }
}

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

    #[test]
    fn lookup_reuses_existing_inode_and_tracks_counts() {
        let mut table = InodeTable::new(String::from("/"), String::from("root"));

        let first = table
            .remember_lookup(String::from("/a"), || Ok(String::from("a")))
            .expect("lookup should allocate inode");
        let second = table
            .remember_lookup(String::from("/a"), || Ok(String::from("a")))
            .expect("lookup should reuse inode");

        assert_eq!(first.ino, second.ino);
        assert_eq!(second.lookup_count, 2);
        assert_eq!(second.generation, 1);
    }

    #[test]
    fn forget_saturates_at_zero() {
        let mut table = InodeTable::new(String::from("/"), String::from("root"));
        let record = table
            .remember_lookup(String::from("/a"), || Ok(String::from("a")))
            .expect("lookup should allocate inode");

        table.forget(record.ino, 2);

        assert!(table.get(record.ino).is_none());
    }

    #[test]
    fn readdir_seen_nodes_do_not_consume_lookup_counts() {
        let mut table = InodeTable::new(String::from("/"), String::from("root"));
        let record = table
            .remember_seen(String::from("/a"), || Ok(String::from("a")))
            .expect("readdir should remember inode");

        assert_eq!(record.lookup_count, 0);
        assert_eq!(table.len(), 2);
    }

    #[test]
    fn evict_unreferenced_drops_readdir_only_entries() {
        let mut table = InodeTable::new(String::from("/"), String::from("root"));
        let record = table
            .remember_seen(String::from("/a"), || Ok(String::from("a")))
            .expect("readdir should remember inode");

        table.evict_unreferenced();

        assert!(table.get(record.ino).is_none());
        assert_eq!(table.len(), 1);
    }
}
