//! Object filtering by glob, regex, size, and date.

use chrono::{DateTime, Utc};
use regex::Regex;

use crate::config::FilterConfig;
use crate::error::{Result, S3boltError};
use crate::types::ObjectInfo;

/// A compiled filter chain that evaluates whether an object should be copied.
///
/// Include globs compose with OR (at least one must match).
/// All other filters compose with AND.
#[derive(Debug)]
pub struct FilterChain {
    include_globs: Vec<String>,
    exclude_globs: Vec<String>,
    key_regex: Option<Regex>,
    min_size: Option<u64>,
    max_size: Option<u64>,
    newer_than: Option<DateTime<Utc>>,
    older_than: Option<DateTime<Utc>>,
}

impl FilterChain {
    /// Compile a `FilterChain` from configuration.
    pub fn from_config(config: &FilterConfig) -> Result<Self> {
        let key_regex = config
            .key_regex
            .as_deref()
            .map(Regex::new)
            .transpose()
            .map_err(S3boltError::RegexError)?;

        Ok(Self {
            include_globs: config.include_globs.clone(),
            exclude_globs: config.exclude_globs.clone(),
            key_regex,
            min_size: config.min_size,
            max_size: config.max_size,
            newer_than: config.newer_than,
            older_than: config.older_than,
        })
    }

    /// Returns `true` if this object passes all filters and should be copied.
    pub fn matches(&self, info: &ObjectInfo) -> bool {
        // Include globs: if specified, at least one must match.
        if !self.include_globs.is_empty()
            && !self
                .include_globs
                .iter()
                .any(|g| glob_match::glob_match(g, &info.key))
        {
            return false;
        }

        // Exclude globs: if any match, reject.
        if self
            .exclude_globs
            .iter()
            .any(|g| glob_match::glob_match(g, &info.key))
        {
            return false;
        }

        // Regex filter.
        if let Some(ref re) = self.key_regex {
            if !re.is_match(&info.key) {
                return false;
            }
        }

        // Size filters.
        if let Some(min) = self.min_size {
            if info.size < min {
                return false;
            }
        }
        if let Some(max) = self.max_size {
            if info.size > max {
                return false;
            }
        }

        // Date filters.
        if let Some(ref newer) = self.newer_than {
            if let Some(ref modified) = info.last_modified {
                if modified < newer {
                    return false;
                }
            }
        }
        if let Some(ref older) = self.older_than {
            if let Some(ref modified) = info.last_modified {
                if modified > older {
                    return false;
                }
            }
        }

        true
    }

    /// Returns `true` if no filters are configured (everything passes).
    pub fn is_empty(&self) -> bool {
        self.include_globs.is_empty()
            && self.exclude_globs.is_empty()
            && self.key_regex.is_none()
            && self.min_size.is_none()
            && self.max_size.is_none()
            && self.newer_than.is_none()
            && self.older_than.is_none()
    }
}

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

    fn make_info(key: &str, size: u64) -> ObjectInfo {
        ObjectInfo {
            key: key.to_owned(),
            size,
            e_tag: None,
            last_modified: None,
            storage_class: None,
        }
    }

    #[test]
    fn empty_filter_matches_all() {
        let chain = FilterChain::from_config(&FilterConfig::default()).unwrap();
        assert!(chain.is_empty());
        assert!(chain.matches(&make_info("anything.txt", 100)));
    }

    #[test]
    fn include_glob_filters() {
        let config = FilterConfig {
            include_globs: vec!["**/*.parquet".to_owned()],
            ..Default::default()
        };
        let chain = FilterChain::from_config(&config).unwrap();

        assert!(chain.matches(&make_info("data/file.parquet", 100)));
        assert!(!chain.matches(&make_info("data/file.csv", 100)));
    }

    #[test]
    fn exclude_glob_filters() {
        let config = FilterConfig {
            exclude_globs: vec!["_tmp/*".to_owned()],
            ..Default::default()
        };
        let chain = FilterChain::from_config(&config).unwrap();

        assert!(chain.matches(&make_info("data/file.parquet", 100)));
        assert!(!chain.matches(&make_info("_tmp/file.parquet", 100)));
    }

    #[test]
    fn size_filters() {
        let config = FilterConfig {
            min_size: Some(100),
            max_size: Some(1000),
            ..Default::default()
        };
        let chain = FilterChain::from_config(&config).unwrap();

        assert!(!chain.matches(&make_info("small.txt", 50)));
        assert!(chain.matches(&make_info("medium.txt", 500)));
        assert!(!chain.matches(&make_info("large.txt", 2000)));
    }

    #[test]
    fn regex_filter() {
        let config = FilterConfig {
            key_regex: Some(r"^data/\d{4}/".to_owned()),
            ..Default::default()
        };
        let chain = FilterChain::from_config(&config).unwrap();

        assert!(chain.matches(&make_info("data/2024/file.txt", 100)));
        assert!(!chain.matches(&make_info("data/tmp/file.txt", 100)));
    }

    #[test]
    fn invalid_regex_returns_error() {
        let config = FilterConfig {
            key_regex: Some("[invalid".to_owned()),
            ..Default::default()
        };
        assert!(FilterChain::from_config(&config).is_err());
    }
}
