use crate::error::MpsError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

fn default_true() -> bool {
    true
}
fn five() -> u64 {
    5
}
fn sixty() -> u64 {
    60
}
fn seven() -> u64 {
    7
}

/// Notification settings — shared between Config and MetaConfig.
/// Defined here to avoid a circular import between config.rs and meta.rs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotifyConfig {
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// How many minutes around a reminder time counts as "due now".
    #[serde(default = "five")]
    pub window_minutes: u64,
    /// Send a morning briefing listing all open tasks.
    #[serde(default = "default_true")]
    pub notify_open_tasks: bool,
    /// If non-empty, only open tasks with one of these tags are included.
    #[serde(default)]
    pub open_task_tags: Vec<String>,
    /// Time-of-day for the morning task briefing, e.g. "9am".
    #[serde(default)]
    pub task_notify_at: Option<String>,
    /// Minimum minutes between repeat notifications for the same reminder.
    #[serde(default = "sixty")]
    pub task_cooldown_minutes: u64,
    /// How many past days to scan for overdue open tasks.
    #[serde(default = "seven")]
    pub overdue_days: u64,
}

impl Default for NotifyConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            window_minutes: 5,
            notify_open_tasks: true,
            open_task_tags: Vec::new(),
            task_notify_at: None,
            task_cooldown_minutes: 60,
            overdue_days: 7,
        }
    }
}

// ── Shared meta (.mps.meta — git-tracked) ────────────────────────────────────

/// Machine-agnostic config layer stored in storage_dir/.mps.meta.
/// Git-tracked: syncs across all devices on `mps autogit`.
/// Fields are union-merged with ~/.mps_config.yaml at startup.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaShared {
    #[serde(default)]
    pub version: u32,
    #[serde(default)]
    pub config: MetaConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaConfig {
    #[serde(default)]
    pub type_aliases: HashMap<String, String>,
    #[serde(default)]
    pub command_aliases: HashMap<String, String>,
    #[serde(default)]
    pub default_command: Option<String>,
    #[serde(default)]
    pub custom_tags: Vec<String>,
    #[serde(default)]
    pub notify: NotifyConfig,
}

impl MetaShared {
    pub fn filename() -> &'static str {
        ".mps.meta"
    }

    pub fn path(storage_dir: &Path) -> PathBuf {
        storage_dir.join(Self::filename())
    }

    /// Load from storage_dir/.mps.meta. Returns Default if file is absent or unparseable.
    pub fn load(storage_dir: &Path) -> Self {
        let path = Self::path(storage_dir);
        if !path.exists() {
            return Self::default();
        }
        std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Atomically write to storage_dir/.mps.meta (tmp + rename).
    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
        let path = Self::path(storage_dir);
        let tmp = path.with_extension(format!("meta.tmp.{}", std::process::id()));
        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
        std::fs::rename(&tmp, &path)?;
        Ok(())
    }
}

// ── Local meta (.mps.local — gitignored) ─────────────────────────────────────

/// Per-device transient state: notification history and cache.
/// Gitignored — never committed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaLocal {
    #[serde(default)]
    pub version: u32,
    /// epoch_ref → unix timestamp (seconds) when notification was last sent.
    #[serde(default)]
    pub notified: HashMap<String, i64>,
    /// "YYYY-MM-DD" of the last morning task briefing notification.
    #[serde(default)]
    pub last_task_date: Option<String>,
    #[serde(default)]
    pub cache: MetaCache,
}

/// Cached tag counts across all .mps files for `mps tags --all`.
///
/// The snapshot records `(filename, size_bytes)` for every .mps file at the time
/// the cache was built.  Before using the cache the caller should check
/// [`MetaCache::is_valid`] against the current file list; if any file was added,
/// removed, or changed in size the cache is stale.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaCache {
    /// Sorted list of (filename, file_size_bytes) at cache-build time.
    #[serde(default)]
    pub files_snapshot: Vec<(String, u64)>,
    /// Tag name → total occurrence count across all cached files.
    #[serde(default)]
    pub tag_counts: HashMap<String, u32>,
}

impl MetaCache {
    /// Return true when `current_files` (sorted list of (filename, size)) matches
    /// the stored snapshot exactly, meaning no file was added, removed, or changed.
    pub fn is_valid(&self, current_files: &[(String, u64)]) -> bool {
        self.files_snapshot == current_files
    }

    /// Build a snapshot from the current .mps file list.
    pub fn build_snapshot(files: &std::path::Path) -> Vec<(String, u64)> {
        let re = crate::constants::mps_file_name_regexp();
        let mut snapshot: Vec<(String, u64)> = std::fs::read_dir(files)
            .map(|rd| {
                rd.filter_map(|e| e.ok())
                    .filter_map(|e| {
                        let name = e.file_name().to_string_lossy().into_owned();
                        if !re.is_match(&name) {
                            return None;
                        }
                        let size = e.metadata().ok()?.len();
                        Some((name, size))
                    })
                    .collect()
            })
            .unwrap_or_default();
        snapshot.sort_by(|a, b| a.0.cmp(&b.0));
        snapshot
    }
}

impl MetaLocal {
    pub fn filename() -> &'static str {
        ".mps.local"
    }

    pub fn path(storage_dir: &Path) -> PathBuf {
        storage_dir.join(Self::filename())
    }

    /// Load from storage_dir/.mps.local. Returns Default if absent or unparseable.
    pub fn load(storage_dir: &Path) -> Self {
        let path = Self::path(storage_dir);
        if !path.exists() {
            return Self::default();
        }
        std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Atomically write to storage_dir/.mps.local (tmp + rename).
    /// Also ensures .mps.local is listed in storage_dir/.gitignore.
    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
        let path = Self::path(storage_dir);
        let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
        std::fs::rename(&tmp, &path)?;
        ensure_local_gitignored(storage_dir);
        Ok(())
    }

    /// Returns true if `epoch_ref` was notified within the last `cooldown_secs` seconds.
    pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
        if let Some(&ts) = self.notified.get(epoch_ref) {
            let now = chrono::Local::now().timestamp();
            return now - ts < cooldown_secs;
        }
        false
    }

    /// Record that `epoch_ref` was notified right now.
    pub fn mark_notified(&mut self, epoch_ref: &str) {
        self.notified
            .insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
    }

    /// Returns true if the task briefing has already been sent today.
    pub fn task_briefing_done_today(&self) -> bool {
        let today = chrono::Local::now()
            .date_naive()
            .format("%Y-%m-%d")
            .to_string();
        self.last_task_date.as_deref() == Some(today.as_str())
    }

    /// Record that the task briefing was sent today.
    pub fn mark_task_briefing(&mut self) {
        self.last_task_date = Some(
            chrono::Local::now()
                .date_naive()
                .format("%Y-%m-%d")
                .to_string(),
        );
    }

    /// Remove notification entries older than `before_ts` (unix seconds).
    pub fn prune(&mut self, before_ts: i64) {
        self.notified.retain(|_, &mut ts| ts >= before_ts);
    }
}

/// Add ".mps.local" to storage_dir/.gitignore if it isn't already there.
/// Silently ignores I/O errors — gitignore is best-effort.
fn ensure_local_gitignored(storage_dir: &Path) {
    let gitignore = storage_dir.join(".gitignore");
    let entry = ".mps.local";
    let already_present = std::fs::read_to_string(&gitignore)
        .map(|s| s.lines().any(|l| l.trim() == entry))
        .unwrap_or(false);
    if !already_present {
        use std::io::Write;
        if let Ok(mut f) = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&gitignore)
        {
            let _ = writeln!(f, "{}", entry);
        }
    }
}

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

    fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        (dir, p)
    }

    #[test]
    fn test_meta_shared_load_absent_returns_default() {
        let (_dir, p) = tmp_store();
        let m = MetaShared::load(&p);
        assert_eq!(m.version, 0);
        assert!(m.config.type_aliases.is_empty());
    }

    #[test]
    fn test_meta_shared_save_load_roundtrip() {
        let (_dir, p) = tmp_store();
        let mut m = MetaShared::default();
        m.version = 1;
        m.config.default_command = Some("list".into());
        m.config.custom_tags = vec!["work".into(), "personal".into()];
        m.config.type_aliases.insert("t".into(), "task".into());
        m.save(&p).unwrap();

        let m2 = MetaShared::load(&p);
        assert_eq!(m2.version, 1);
        assert_eq!(m2.config.default_command.as_deref(), Some("list"));
        assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
        assert_eq!(
            m2.config.type_aliases.get("t").map(|s| s.as_str()),
            Some("task")
        );
    }

    #[test]
    fn test_meta_local_load_absent_returns_default() {
        let (_dir, p) = tmp_store();
        let m = MetaLocal::load(&p);
        assert!(m.notified.is_empty());
        assert!(m.last_task_date.is_none());
    }

    #[test]
    fn test_meta_local_save_load_roundtrip() {
        let (_dir, p) = tmp_store();
        let mut m = MetaLocal::default();
        m.notified.insert("20260524.1".into(), 1000000);
        m.last_task_date = Some("2026-05-24".into());
        m.save(&p).unwrap();

        let m2 = MetaLocal::load(&p);
        assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
        assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
    }

    #[test]
    fn test_was_notified_within_cooldown() {
        let mut m = MetaLocal::default();
        let now = chrono::Local::now().timestamp();
        m.notified.insert("ref-1".into(), now - 30); // notified 30s ago
        assert!(m.was_notified("ref-1", 60)); // cooldown 60s → still fresh
        assert!(!m.was_notified("ref-1", 20)); // cooldown 20s → expired
    }

    #[test]
    fn test_was_notified_absent_returns_false() {
        let m = MetaLocal::default();
        assert!(!m.was_notified("no-such-ref", 3600));
    }

    #[test]
    fn test_mark_notified_sets_timestamp() {
        let mut m = MetaLocal::default();
        assert!(!m.was_notified("ref-2", 60));
        m.mark_notified("ref-2");
        assert!(m.was_notified("ref-2", 60));
    }

    #[test]
    fn test_task_briefing_done_today_false_by_default() {
        let m = MetaLocal::default();
        assert!(!m.task_briefing_done_today());
    }

    #[test]
    fn test_mark_task_briefing_sets_today() {
        let mut m = MetaLocal::default();
        m.mark_task_briefing();
        assert!(m.task_briefing_done_today());
    }

    #[test]
    fn test_task_briefing_done_yesterday_is_false() {
        let mut m = MetaLocal::default();
        m.last_task_date = Some("2000-01-01".into()); // long past
        assert!(!m.task_briefing_done_today());
    }

    #[test]
    fn test_prune_removes_old_entries() {
        let mut m = MetaLocal::default();
        m.notified.insert("old".into(), 1000);
        m.notified.insert("new".into(), 9_000_000_000);
        m.prune(5_000_000);
        assert!(!m.notified.contains_key("old"));
        assert!(m.notified.contains_key("new"));
    }

    #[test]
    fn test_prune_keeps_entries_at_boundary() {
        let mut m = MetaLocal::default();
        m.notified.insert("exact".into(), 5000);
        m.prune(5000); // >= 5000 → kept
        assert!(m.notified.contains_key("exact"));
    }

    // ── Iteration 11: MetaLocal save auto-gitignores .mps.local ──────────────

    #[test]
    fn test_save_auto_adds_mps_local_to_gitignore() {
        let (_dir, p) = tmp_store();
        let m = MetaLocal::default();
        m.save(&p).unwrap();

        let gitignore = p.join(".gitignore");
        assert!(gitignore.exists(), ".gitignore must be created");
        let content = std::fs::read_to_string(&gitignore).unwrap();
        assert!(
            content.lines().any(|l| l.trim() == ".mps.local"),
            ".gitignore must contain .mps.local"
        );
    }

    #[test]
    fn test_save_does_not_duplicate_gitignore_entry() {
        let (_dir, p) = tmp_store();
        // Write .gitignore with entry already present.
        std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
        let m = MetaLocal::default();
        m.save(&p).unwrap();
        m.save(&p).unwrap(); // save twice

        let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
        let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
        assert_eq!(count, 1, "entry must not be duplicated");
    }

    // ── Iteration 12: MetaShared corrupted JSON falls back to default ─────────

    #[test]
    fn test_meta_shared_corrupted_json_returns_default() {
        let (_dir, p) = tmp_store();
        std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
        let m = MetaShared::load(&p);
        // Must return Default, not panic.
        assert_eq!(m.version, 0);
        assert!(m.config.type_aliases.is_empty());
    }

    #[test]
    fn test_meta_local_corrupted_json_returns_default() {
        let (_dir, p) = tmp_store();
        std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
        let m = MetaLocal::load(&p);
        assert!(m.notified.is_empty());
    }

    // ── Iteration 13: was_notified exactly at cooldown boundary ─────────────

    #[test]
    fn test_was_notified_exactly_at_cooldown_is_fresh() {
        let mut m = MetaLocal::default();
        let now = chrono::Local::now().timestamp();
        // ts = now − cooldown → age == cooldown → age < cooldown is false → NOT fresh.
        m.notified.insert("ref".into(), now - 60);
        // was_notified uses `now - ts < cooldown_secs`, so at exactly the boundary it's false.
        assert!(
            !m.was_notified("ref", 60),
            "at exactly cooldown, entry is expired"
        );
        // Just inside cooldown (59s ago) → still fresh.
        m.notified.insert("ref".into(), now - 59);
        assert!(
            m.was_notified("ref", 60),
            "59s ago with 60s cooldown → fresh"
        );
    }

    // ── Iteration 14: MetaShared save is atomic (tmp file cleaned up) ─────────

    #[test]
    fn test_meta_shared_atomic_save_no_tmp_file_left() {
        let (_dir, p) = tmp_store();
        let m = MetaShared::default();
        m.save(&p).unwrap();
        // Confirm no .tmp file remains.
        let leftovers: Vec<_> = std::fs::read_dir(&p)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
            .collect();
        assert!(
            leftovers.is_empty(),
            "no .tmp files should remain after save"
        );
    }

    #[test]
    fn test_meta_local_atomic_save_no_tmp_file_left() {
        let (_dir, p) = tmp_store();
        let m = MetaLocal::default();
        m.save(&p).unwrap();
        let leftovers: Vec<_> = std::fs::read_dir(&p)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| {
                let n = e.file_name();
                let s = n.to_string_lossy();
                s.contains(".tmp") && s.contains("local")
            })
            .collect();
        assert!(
            leftovers.is_empty(),
            "no .tmp files should remain after save"
        );
    }

    // ── MetaCache::is_valid / build_snapshot ──────────────────────────────────

    #[test]
    fn test_meta_cache_valid_when_snapshot_matches() {
        let mut cache = crate::meta::MetaCache::default();
        let snapshot = vec![("20260101.1000.mps".into(), 42u64)];
        cache.files_snapshot = snapshot.clone();
        assert!(cache.is_valid(&snapshot));
    }

    #[test]
    fn test_meta_cache_invalid_when_file_size_changed() {
        let mut cache = crate::meta::MetaCache::default();
        cache.files_snapshot = vec![("20260101.1000.mps".into(), 42u64)];
        let current = vec![("20260101.1000.mps".into(), 99u64)]; // size changed
        assert!(!cache.is_valid(&current));
    }

    #[test]
    fn test_meta_cache_invalid_when_file_added() {
        let mut cache = crate::meta::MetaCache::default();
        cache.files_snapshot = vec![("20260101.1000.mps".into(), 42u64)];
        let current = vec![
            ("20260101.1000.mps".into(), 42u64),
            ("20260102.1001.mps".into(), 10u64), // new file
        ];
        assert!(!cache.is_valid(&current));
    }

    #[test]
    fn test_meta_cache_invalid_when_empty_vs_nonempty() {
        let cache = crate::meta::MetaCache::default(); // empty snapshot
        let current = vec![("20260101.1000.mps".into(), 42u64)];
        assert!(!cache.is_valid(&current));
    }

    #[test]
    fn test_meta_cache_valid_when_both_empty() {
        let cache = crate::meta::MetaCache::default();
        assert!(cache.is_valid(&[]));
    }

    #[test]
    fn test_build_snapshot_returns_sorted_mps_files_only() {
        let (_dir, p) = tmp_store();
        std::fs::write(p.join("20260101.1000000000.mps"), "content-a").unwrap();
        std::fs::write(p.join("20260102.1000000001.mps"), "content-b").unwrap();
        std::fs::write(p.join("not-an-mps.txt"), "ignored").unwrap();

        let snapshot = crate::meta::MetaCache::build_snapshot(&p);
        assert_eq!(snapshot.len(), 2, "only .mps files");
        assert_eq!(snapshot[0].0, "20260101.1000000000.mps");
        assert_eq!(snapshot[1].0, "20260102.1000000001.mps");
        // sizes should match what we wrote
        assert_eq!(snapshot[0].1, b"content-a".len() as u64);
        assert_eq!(snapshot[1].1, b"content-b".len() as u64);
    }

    #[test]
    fn test_build_snapshot_empty_dir() {
        let (_dir, p) = tmp_store();
        let snapshot = crate::meta::MetaCache::build_snapshot(&p);
        assert!(snapshot.is_empty());
    }
}
