//! File cache with TTL-based expiry and symlink integration.
//!
//! Files are cached by SHA-256 hash of their source URL. On cache hit, a symlink
//! is created from the output path to the cached file. On cache miss, the file
//! is downloaded into the cache directory and then symlinked.
use anyhow::{bail, Context, Result};
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// Metadata stored alongside each cached file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
    /// The original URL that produced this cache entry.
    pub url: String,
    /// When the file was cached.
    pub cached_at: String,
    /// File size in bytes.
    pub size: u64,
}

/// Statistics about the cache.
#[derive(Debug, Clone)]
pub struct CacheStats {
    /// Number of cache hits.
    pub hit_count: usize,
    /// Number of cache misses.
    pub miss_count: usize,
    /// Total size of all cached files in bytes.
    pub total_size: u64,
    /// Number of entries in the cache.
    pub entry_count: usize,
}

/// A file cache with TTL-based expiry.
///
/// Files are stored in subdirectories keyed by the first two characters
/// of their SHA-256 hash, with the full hash as the filename.
///
/// # Example
///
/// ```ignore
/// use rss_core::cache::FileCache;
/// use std::time::Duration;
///
/// let cache = FileCache::new(
///     PathBuf::from("~/.rss_cache"),
///     Duration::from_secs(604800), // 7 days
/// );
///
/// // Check if a URL is cached
/// if let Some(cached_path) = cache.get("https://example.com/file.tif") {
///     // Use cached_path directly or create a symlink
/// }
/// ```
pub struct FileCache {
    /// Root directory for the cache.
    root: PathBuf,
    /// Time-to-live for cache entries.
    ttl: Duration,
    /// Number of cache hits.
    hit_count: usize,
    /// Number of cache misses.
    miss_count: usize,
}

impl FileCache {
    /// Create a new `FileCache` with the given root directory and TTL.
    ///
    /// The root directory is created if it does not exist.
    ///
    /// # Arguments
    ///
    /// * `root` — Path to the cache root directory
    /// * `ttl` — Time-to-live for cache entries
    ///
    /// # Errors
    ///
    /// Returns an error if the cache directory cannot be created.
    pub fn new(root: PathBuf, ttl: Duration) -> Result<Self> {
        fs::create_dir_all(&root).context("Failed to create cache directory")?;
        Ok(FileCache {
            root,
            ttl,
            hit_count: 0,
            miss_count: 0,
        })
    }

    /// Create a `FileCache` from environment variables.
    ///
    /// Reads `RSS_CACHE_DIR` for the cache directory (default: `~/.rss_cache`)
    /// and `RSS_CACHE_TTL` for the TTL in seconds (default: 604800 = 7 days).
    pub fn from_env() -> Result<Self> {
        let root = std::env::var("RSS_CACHE_DIR").map(PathBuf::from).unwrap_or_else(|_| {
            let home = std::env::var("HOME")
                .map(PathBuf::from)
                .unwrap_or_else(|_| PathBuf::from("/tmp"));
            home.join(".rss_cache")
        });

        let ttl_secs: u64 = std::env::var("RSS_CACHE_TTL")
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(604800); // 7 days
        let ttl = Duration::from_secs(ttl_secs);

        Self::new(root, ttl)
    }

    /// Compute the SHA-256 hash of a URL.
    pub fn url_hash(url: &str) -> String {
        let mut hasher = Sha256::new();
        hasher.update(url.as_bytes());
        hex::encode(hasher.finalize())
    }

    /// Get the TTL duration for this cache.
    pub fn ttl(&self) -> Duration {
        self.ttl
    }

    /// Get the cache file path for a given URL.
    pub fn cache_path(&self, url: &str) -> PathBuf {
        let hash = Self::url_hash(url);
        let prefix = &hash[..2];
        self.root.join(prefix).join(&hash)
    }

    /// Get the metadata file path for a given URL.
    pub fn meta_path(&self, url: &str) -> PathBuf {
        let mut p = self.cache_path(url);
        p.set_extension("meta");
        p
    }

    /// Check if a URL is cached and not expired.
    ///
    /// Returns the cached file path if the file exists and is within TTL.
    /// Increments hit/miss counters accordingly.
    ///
    /// # Arguments
    ///
    /// * `url` — The source URL to check
    pub fn get(&mut self, url: &str) -> Option<PathBuf> {
        let cache_path = self.cache_path(url);
        let meta_path = self.meta_path(url);

        if !cache_path.exists() || !meta_path.exists() {
            self.miss_count += 1;
            return None;
        }

        // Check TTL
        let meta = fs::read_to_string(&meta_path).ok()?;
        let entry: CacheEntry = serde_json::from_str(&meta).ok()?;
        let cached_at = DateTime::parse_from_rfc3339(&entry.cached_at).ok()?;
        let now = chrono::Utc::now();

        if now.signed_duration_since(cached_at).to_std().ok()? > self.ttl {
            self.miss_count += 1;
            return None;
        }

        self.hit_count += 1;
        Some(cache_path)
    }

    /// Download a file from a URL into the cache directory.
    ///
    /// Uses `reqwest` to download the file. If the cache entry already exists
    /// and is not expired, returns the existing cached path without downloading.
    ///
    /// # Arguments
    ///
    /// * `url` — The source URL to download
    /// * `client` — The reqwest HTTP client to use
    ///
    /// # Returns
    ///
    /// The path to the cached file.
    pub fn download_and_cache(
        &mut self,
        url: &str,
        client: &reqwest::blocking::Client,
    ) -> Result<PathBuf> {
        let cache_path = self.cache_path(url);

        // If already cached and not expired, return it
        if let Some(path) = self.get(url) {
            return Ok(path);
        }

        // Download the file
        let response = client
            .get(url)
            .send()
            .context("Failed to send request")?;

        if !response.status().is_success() {
            bail!(
                "Failed to download {}: status {}",
                url,
                response.status()
            );
        }

        let bytes = response.bytes().context("Failed to read response body")?;
        let size = bytes.len() as u64;

        // Ensure parent directory exists
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent).context("Failed to create cache subdirectory")?;
        }

        // Write the file
        fs::write(&cache_path, &bytes).context("Failed to write cached file")?;

        // Write metadata
        let entry = CacheEntry {
            url: url.to_string(),
            cached_at: chrono::Utc::now().to_rfc3339(),
            size,
        };
        let meta_path = self.meta_path(url);
        let meta_json = serde_json::to_string(&entry).context("Failed to serialize cache entry")?;
        fs::write(&meta_path, meta_json).context("Failed to write cache metadata")?;

        Ok(cache_path)
    }

    /// Create a symlink from `link_path` to the cached file for `url`.
    ///
    /// Returns an error if the URL is not cached or the symlink cannot be created.
    ///
    /// # Arguments
    ///
    /// * `url` — The source URL to look up in the cache
    /// * `link_path` — The path where the symlink should be created
    pub fn symlink(&mut self, url: &str, link_path: &Path) -> Result<()> {
        let cache_path = self
            .get(url)
            .ok_or_else(|| anyhow::anyhow!("URL not cached: {}", url))?;

        // Ensure parent directory exists
        if let Some(parent) = link_path.parent() {
            fs::create_dir_all(parent).context("Failed to create parent directory for symlink")?;
        }

        // Remove existing file/symlink
        if link_path.exists() {
            fs::remove_file(link_path).context("Failed to remove existing file")?;
        }

        #[cfg(unix)]
        std::os::unix::fs::symlink(&cache_path, link_path)
            .context("Failed to create symlink")?;

        #[cfg(windows)]
        std::os::windows::fs::symlink_file(&cache_path, link_path)
            .context("Failed to create symlink")?;

        Ok(())
    }

    /// Remove a specific entry from the cache.
    ///
    /// Returns `true` if the entry existed and was removed, `false` otherwise.
    ///
    /// # Arguments
    ///
    /// * `url` — The source URL to invalidate
    pub fn invalidate(&self, url: &str) -> bool {
        let cache_path = self.cache_path(url);
        let meta_path = self.meta_path(url);

        let mut removed = false;
        if cache_path.exists() {
            if fs::remove_file(&cache_path).is_ok() {
                removed = true;
            }
        }
        if meta_path.exists() {
            if fs::remove_file(&meta_path).is_ok() {
                removed = true;
            }
        }
        removed
    }

    /// Remove all expired entries from the cache.
    ///
    /// Returns the number of entries removed.
    pub fn cleanup(&self) -> usize {
        let mut removed = 0;

        if !self.root.exists() {
            return 0;
        }

        let prefix_entries = match fs::read_dir(&self.root) {
            Ok(r) => r,
            Err(_) => return 0,
        };

        for prefix_entry in prefix_entries.flatten() {
            let prefix_dir = prefix_entry.path();
            if !prefix_dir.is_dir() {
                continue;
            }

            let entries = match fs::read_dir(&prefix_dir) {
                Ok(r) => r,
                Err(_) => continue,
            };

            for entry in entries.flatten() {
                let path = entry.path();
                if !path.is_file() {
                    continue;
                }

                // Skip metadata files (we process them with their data file)
                if path.extension().map_or(false, |ext| ext == "meta") {
                    continue;
                }

                let meta_path = {
                    let mut m = path.clone();
                    m.set_extension("meta");
                    m
                };

                if !meta_path.exists() {
                    continue;
                }

                let meta = match fs::read_to_string(&meta_path) {
                    Ok(m) => m,
                    Err(_) => continue,
                };
                let entry: CacheEntry = match serde_json::from_str(&meta) {
                    Ok(e) => e,
                    Err(_) => continue,
                };
                let cached_at = match DateTime::parse_from_rfc3339(&entry.cached_at) {
                    Ok(t) => t,
                    Err(_) => continue,
                };
                let now = chrono::Utc::now();

                if let Ok(elapsed) = now.signed_duration_since(cached_at).to_std() {
                    if elapsed > self.ttl {
                        let _ = fs::remove_file(&path);
                        let _ = fs::remove_file(&meta_path);
                        removed += 1;
                    }
                }
            }
        }

        removed
    }

    /// Get statistics about the cache.
    pub fn stats(&self) -> CacheStats {
        let mut total_size: u64 = 0;
        let mut entry_count = 0;

        if !self.root.exists() {
            return CacheStats {
                hit_count: self.hit_count,
                miss_count: self.miss_count,
                total_size: 0,
                entry_count: 0,
            };
        }

        let prefix_entries = match fs::read_dir(&self.root) {
            Ok(r) => r,
            Err(_) => {
                return CacheStats {
                    hit_count: self.hit_count,
                    miss_count: self.miss_count,
                    total_size,
                    entry_count,
                }
            }
        };

        for prefix_entry in prefix_entries.flatten() {
            let prefix_dir = prefix_entry.path();
            if !prefix_dir.is_dir() {
                continue;
            }

            let entries = match fs::read_dir(&prefix_dir) {
                Ok(r) => r,
                Err(_) => continue,
            };

            for entry in entries.flatten() {
                let path = entry.path();
                if !path.is_file() || path.extension().map_or(false, |ext| ext == "meta") {
                    continue;
                }

                entry_count += 1;
                if let Ok(metadata) = fs::metadata(&path) {
                    total_size += metadata.len();
                }
            }
        }

        CacheStats {
            hit_count: self.hit_count,
            miss_count: self.miss_count,
            total_size,
            entry_count,
        }
    }
}

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

    fn make_cache(ttl: Duration) -> (FileCache, TempDir) {
        let dir = TempDir::new().expect("Failed to create temp dir");
        let cache = FileCache::new(dir.path().to_path_buf(), ttl)
            .expect("Failed to create cache");
        (cache, dir)
    }

    fn make_test_cache() -> (FileCache, TempDir) {
        make_cache(Duration::from_secs(3600)) // 1 hour TTL
    }

    #[test]
    fn test_cache_new_with_params() {
        let dir = TempDir::new().expect("Failed to create temp dir");
        let cache = FileCache::new(dir.path().to_path_buf(), Duration::from_secs(3600))
            .expect("Failed to create cache");
        assert!(cache.root.exists());
    }

    #[test]
    fn test_cache_get_miss() {
        let (mut cache, _dir) = make_test_cache();
        let result = cache.get("https://example.com/nonexistent.tif");
        assert!(result.is_none());
        let stats = cache.stats();
        assert_eq!(stats.miss_count, 1);
    }

    #[test]
    fn test_cache_download_and_cache_hit() {
        let (mut cache, _dir) = make_test_cache();
        // Use a mock server or a simple HTTP server for testing
        // For now, we'll test with a local file by directly writing to cache
        let url = "https://example.com/test.tif";
        let cache_path = cache.cache_path(url);

        // Simulate a cached file by writing directly
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path, b"test data").expect("Failed to write test file");

        let entry = CacheEntry {
            url: url.to_string(),
            cached_at: chrono::Utc::now().to_rfc3339(),
            size: 9,
        };
        let meta_path = cache.meta_path(url);
        let meta_json = serde_json::to_string(&entry).expect("Failed to serialize");
        fs::write(&meta_path, meta_json).expect("Failed to write meta");

        // Now the cache should find it
        let result = cache.get(url);
        assert!(result.is_some());
        let cached = result.unwrap();
        assert_eq!(cached, cache_path);
        let stats = cache.stats();
        assert_eq!(stats.hit_count, 1);
    }

    #[test]
    fn test_cache_creates_symlink() {
        let (mut cache, dir) = make_test_cache();
        let url = "https://example.com/symlink_test.tif";

        // Simulate a cached file
        let cache_path = cache.cache_path(url);
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path, b"symlink test data").expect("Failed to write test file");

        let entry = CacheEntry {
            url: url.to_string(),
            cached_at: chrono::Utc::now().to_rfc3339(),
            size: 17,
        };
        let meta_path = cache.meta_path(url);
        let meta_json = serde_json::to_string(&entry).expect("Failed to serialize");
        fs::write(&meta_path, meta_json).expect("Failed to write meta");

        // Create symlink
        let link_path = dir.path().join("output").join("test.tif");
        cache.symlink(url, &link_path).expect("Failed to create symlink");

        assert!(link_path.exists());
        #[cfg(unix)]
        assert!(link_path.is_symlink());
        #[cfg(windows)]
        assert!(link_path.is_file()); // Windows symlinks appear as files
    }

    #[test]
    fn test_cache_ttl_expiry() {
        let (mut cache, _dir) = make_cache(Duration::from_secs(1)); // 1 second TTL
        let url = "https://example.com/expiring.tif";

        // Create a cached file
        let cache_path = cache.cache_path(url);
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path, b"expiring data").expect("Failed to write test file");

        // Write metadata with a timestamp in the past
        let past = chrono::Utc::now() - chrono::TimeDelta::seconds(10);
        let entry = CacheEntry {
            url: url.to_string(),
            cached_at: past.to_rfc3339(),
            size: 13,
        };
        let meta_path = cache.meta_path(url);
        let meta_json = serde_json::to_string(&entry).expect("Failed to serialize");
        fs::write(&meta_path, meta_json).expect("Failed to write meta");

        // Should be expired
        let result = cache.get(url);
        assert!(result.is_none());
    }

    #[test]
    fn test_cache_invalidate() {
        let (mut cache, _dir) = make_test_cache();
        let url = "https://example.com/invalidate.tif";

        // Create a cached file
        let cache_path = cache.cache_path(url);
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path, b"invalidate data").expect("Failed to write test file");

        let entry = CacheEntry {
            url: url.to_string(),
            cached_at: chrono::Utc::now().to_rfc3339(),
            size: 15,
        };
        let meta_path = cache.meta_path(url);
        let meta_json = serde_json::to_string(&entry).expect("Failed to serialize");
        fs::write(&meta_path, meta_json).expect("Failed to write meta");

        // Verify it exists
        assert!(cache.get(url).is_some());

        // Invalidate
        let removed = cache.invalidate(url);
        assert!(removed);

        // Should be gone
        assert!(cache.get(url).is_none());
        assert!(!cache_path.exists());
        assert!(!meta_path.exists());
    }

    #[test]
    fn test_cache_cleanup_removes_expired() {
        let (cache, _dir) = make_cache(Duration::from_secs(1)); // 1 second TTL
        let url1 = "https://example.com/expired.tif";
        let url2 = "https://example.com/fresh.tif";

        // Create expired entry
        let cache_path1 = cache.cache_path(url1);
        if let Some(parent) = cache_path1.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path1, b"expired").expect("Failed to write test file");
        let past = chrono::Utc::now() - chrono::TimeDelta::seconds(10);
        let entry1 = CacheEntry {
            url: url1.to_string(),
            cached_at: past.to_rfc3339(),
            size: 7,
        };
        let meta_path1 = cache.meta_path(url1);
        let meta_json1 = serde_json::to_string(&entry1).expect("Failed to serialize");
        fs::write(&meta_path1, meta_json1).expect("Failed to write meta");

        // Create fresh entry
        let cache_path2 = cache.cache_path(url2);
        if let Some(parent) = cache_path2.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path2, b"fresh").expect("Failed to write test file");
        let entry2 = CacheEntry {
            url: url2.to_string(),
            cached_at: chrono::Utc::now().to_rfc3339(),
            size: 5,
        };
        let meta_path2 = cache.meta_path(url2);
        let meta_json2 = serde_json::to_string(&entry2).expect("Failed to serialize");
        fs::write(&meta_path2, meta_json2).expect("Failed to write meta");

        // Cleanup
        let removed = cache.cleanup();
        assert_eq!(removed, 1);

        // Expired should be gone, fresh should remain
        assert!(!cache_path1.exists());
        assert!(cache_path2.exists());
    }

    #[test]
    fn test_cache_stats_tracking() {
        let (mut cache, _dir) = make_test_cache();

        // Initial stats
        let stats = cache.stats();
        assert_eq!(stats.hit_count, 0);
        assert_eq!(stats.miss_count, 0);
        assert_eq!(stats.entry_count, 0);

        // Miss
        cache.get("https://example.com/miss.tif");
        let stats = cache.stats();
        assert_eq!(stats.miss_count, 1);

        // Hit (create a cached entry first)
        let url = "https://example.com/stat_test.tif";
        let cache_path = cache.cache_path(url);
        if let Some(parent) = cache_path.parent() {
            fs::create_dir_all(parent).expect("Failed to create parent");
        }
        fs::write(&cache_path, b"stats test").expect("Failed to write test file");
        let entry = CacheEntry {
            url: url.to_string(),
            cached_at: chrono::Utc::now().to_rfc3339(),
            size: 10,
        };
        let meta_path = cache.meta_path(url);
        let meta_json = serde_json::to_string(&entry).expect("Failed to serialize");
        fs::write(&meta_path, meta_json).expect("Failed to write meta");

        cache.get(url);
        let stats = cache.stats();
        assert_eq!(stats.hit_count, 1);
        assert_eq!(stats.entry_count, 1);
        assert_eq!(stats.total_size, 10);
    }

    #[test]
    fn test_cache_hash_different_urls() {
        let hash1 = FileCache::url_hash("https://example.com/file1.tif");
        let hash2 = FileCache::url_hash("https://example.com/file2.tif");
        assert_ne!(hash1, hash2);

        // Same URL produces same hash
        let hash3 = FileCache::url_hash("https://example.com/file1.tif");
        assert_eq!(hash1, hash3);
    }

    #[test]
    fn test_from_env_defaults() {
        // Unset env vars to test defaults
        std::env::remove_var("RSS_CACHE_DIR");
        std::env::remove_var("RSS_CACHE_TTL");

        let cache = FileCache::from_env().expect("Failed to create cache from env");
        // Default TTL should be 7 days
        assert_eq!(cache.ttl, Duration::from_secs(604800));
    }

    #[test]
    fn test_from_env_custom() {
        let dir = TempDir::new().expect("Failed to create temp dir");
        std::env::set_var("RSS_CACHE_DIR", dir.path().to_str().unwrap());
        std::env::set_var("RSS_CACHE_TTL", "1800");

        let cache = FileCache::from_env().expect("Failed to create cache from env");
        assert_eq!(cache.root, dir.path());
        assert_eq!(cache.ttl, Duration::from_secs(1800));

        // Cleanup
        std::env::remove_var("RSS_CACHE_DIR");
        std::env::remove_var("RSS_CACHE_TTL");
    }
}
