//! Integration tests — mirrors Ruby's test/integration_test.rb and test/store_test.rb.
//! Uses real fixture files from tests/fixtures/*.mps.

use chrono::NaiveDate;
use indexmap::IndexMap;
use mps::{elements::*, parser, ref_resolver::RefResolver, store::Store};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

// ── Fixture helpers ──────────────────────────────────────────────────────────

fn fixtures_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
}

fn fixture_path(name: &str) -> PathBuf {
    fixtures_dir().join(name)
}

fn parse_fixture(name: &str) -> IndexMap<String, Element> {
    parser::parse_file(&fixture_path(name)).expect("fixture parse failed")
}

fn non_root(elements: &IndexMap<String, Element>) -> Vec<&Element> {
    elements
        .values()
        .filter(|e| !e.is_mps_group() && !e.is_unknown())
        .collect()
}

fn fixture_store() -> (tempfile::TempDir, Store) {
    let dir = tempfile::tempdir().unwrap();
    for entry in std::fs::read_dir(fixtures_dir()).unwrap() {
        let entry = entry.unwrap();
        let dst = dir.path().join(entry.file_name());
        std::fs::copy(entry.path(), dst).unwrap();
    }
    let store = Store::new(dir.path());
    (dir, store)
}

// ── Fixtures available ───────────────────────────────────────────────────────
//   20260101.1000000001.mps — bare elements, no tags
//   20260102.1000000002.mps — tags, nested tasks, mps block
//   20260201.1000000010.mps — sprint planning (SPRINT_FILE)
//   20260215.1000000020.mps — rust/learning day  (RUST_FILE)
//   20260220.1000000030.mps — release day        (RELEASE_FILE)

const SPRINT_FILE: &str = "20260201.1000000010.mps";
const RUST_FILE: &str = "20260215.1000000020.mps";
const RELEASE_FILE: &str = "20260220.1000000030.mps";

// ── Bare elements (20260101) ─────────────────────────────────────────────────

#[test]
fn test_bare_file_has_task_note_reminder_log() {
    let els = parse_fixture("20260101.1000000001.mps");
    let leafs = non_root(&els);
    assert!(leafs.iter().any(|e| e.kind() == ElementKind::Task));
    assert!(leafs.iter().any(|e| e.kind() == ElementKind::Note));
    assert!(leafs.iter().any(|e| e.kind() == ElementKind::Reminder));
    assert!(leafs.iter().any(|e| e.kind() == ElementKind::Log));
}

#[test]
fn test_bare_file_task_is_open() {
    let els = parse_fixture("20260101.1000000001.mps");
    let task = els
        .values()
        .find(|e| e.kind() == ElementKind::Task)
        .unwrap();
    if let Element::Task { data, .. } = task {
        assert!(data.is_open());
    } else {
        panic!("not a task");
    }
}

// ── Tagged nested file (20260102) ────────────────────────────────────────────

#[test]
fn test_tagged_file_task_has_tags() {
    let els = parse_fixture("20260102.1000000002.mps");
    // Parser inserts in close-order (depth-first), so check all tasks for the expected tags.
    let has_tags = els
        .values()
        .filter(|e| e.kind() == ElementKind::Task)
        .any(|e| e.tags().contains(&"x".to_string()) && e.tags().contains(&"y".to_string()));
    assert!(has_tags, "expected a task with tags x and y");
}

#[test]
fn test_tagged_file_has_nested_task() {
    let els = parse_fixture("20260102.1000000002.mps");
    // Root wrapper + top-level task (with nested task) + reminder + log + mps group + nested elements
    let task_count = els
        .values()
        .filter(|e| e.kind() == ElementKind::Task)
        .count();
    assert!(
        task_count >= 2,
        "should have at least a top-level and nested task"
    );
}

#[test]
fn test_tagged_file_reminder_at_5pm() {
    let els = parse_fixture("20260102.1000000002.mps");
    let reminder = els
        .values()
        .find(|e| e.kind() == ElementKind::Reminder)
        .unwrap();
    if let Element::Reminder { data, .. } = reminder {
        assert_eq!(data.at.as_deref(), Some("5pm"));
    } else {
        panic!("not a reminder");
    }
}

#[test]
fn test_tagged_file_mps_group_has_task_and_note() {
    let els = parse_fixture("20260102.1000000002.mps");
    // The @mps group should contain a nested task and note
    let nested_task = els
        .values()
        .any(|e| e.kind() == ElementKind::Task && e.body_str().contains("nested task"));
    let nested_note = els
        .values()
        .any(|e| e.kind() == ElementKind::Note && e.body_str().contains("Nested note"));
    assert!(nested_task, "nested task inside @mps expected");
    assert!(nested_note, "nested note inside @mps expected");
}

// ── Sprint planning fixture (20260201) ───────────────────────────────────────

#[test]
fn test_sprint_tasks_count() {
    let els = parse_fixture(SPRINT_FILE);
    let tasks: Vec<&Element> = els
        .values()
        .filter(|e| e.kind() == ElementKind::Task)
        .collect();
    assert_eq!(tasks.len(), 4);
}

#[test]
fn test_sprint_open_tasks() {
    let els = parse_fixture(SPRINT_FILE);
    let open: Vec<&Element> = els
        .values()
        .filter(|e| matches!(e, Element::Task { data, .. } if data.is_open()))
        .collect();
    assert_eq!(open.len(), 3);
}

#[test]
fn test_sprint_done_task_body() {
    let els = parse_fixture(SPRINT_FILE);
    let done: Vec<&Element> = els
        .values()
        .filter(|e| matches!(e, Element::Task { data, .. } if data.is_done()))
        .collect();
    assert_eq!(done.len(), 1);
    assert!(done[0].body_str().contains("Set up the PostgreSQL schema"));
}

#[test]
fn test_sprint_log_duration_210_minutes() {
    let els = parse_fixture(SPRINT_FILE);
    let log = els.values().find(|e| e.kind() == ElementKind::Log).unwrap();
    if let Element::Log { data, .. } = log {
        assert_eq!(data.duration_minutes(), Some(210)); // 3h30m
        assert_eq!(data.duration_str(), Some("3h30m".into()));
    } else {
        panic!("not a log");
    }
}

#[test]
fn test_sprint_reminder_at_3pm() {
    let els = parse_fixture(SPRINT_FILE);
    let rem = els
        .values()
        .find(|e| e.kind() == ElementKind::Reminder)
        .unwrap();
    if let Element::Reminder { data, .. } = rem {
        assert_eq!(data.at.as_deref(), Some("3pm"));
    } else {
        panic!("not a reminder");
    }
}

#[test]
fn test_sprint_nested_tagged_tasks() {
    let els = parse_fixture(SPRINT_FILE);
    let backend_or_frontend = els
        .values()
        .filter(|e| e.kind() == ElementKind::Task)
        .filter(|e| e.tags().iter().any(|t| t == "backend" || t == "frontend"))
        .count();
    assert!(
        backend_or_frontend >= 3,
        "expected ≥ 3 backend/frontend tasks"
    );
}

#[test]
fn test_sprint_ref_resolver_top_level() {
    let els = parse_fixture(SPRINT_FILE);
    let r = RefResolver::new(&els);
    // The @mps group at top level should be mps-1
    let mps_refs: Vec<_> = els
        .keys()
        .filter(|k| {
            let e = &els[*k];
            e.is_mps_group() && k.split('.').count() == 2
        })
        .collect();
    for epoch_ref in mps_refs {
        let human = r.to_human(epoch_ref).expect("mps group should be mapped");
        assert!(human.starts_with("mps-"));
    }
}

#[test]
fn test_sprint_ref_resolver_nested_refs() {
    let els = parse_fixture(SPRINT_FILE);
    let r = RefResolver::new(&els);
    // All tasks inside the @mps group should get nested refs like mps-1.1, mps-1.2, ...
    for (epoch_ref, el) in &els {
        if el.kind() == ElementKind::Task && epoch_ref.split('.').count() == 3 {
            let human = r.to_human(epoch_ref);
            assert!(
                human.is_some(),
                "nested task {} should have a human ref",
                epoch_ref
            );
            let h = human.unwrap();
            assert!(
                h.contains('.'),
                "nested human ref should contain a dot: {}",
                h
            );
        }
    }
}

// ── Rust/learning fixture (20260215) ─────────────────────────────────────────

#[test]
fn test_rust_done_reading_tasks() {
    let els = parse_fixture(RUST_FILE);
    let done_reading: Vec<_> = els
        .values()
        .filter(|e| {
            matches!(e, Element::Task { data, .. } if data.is_done())
                && e.tags().contains(&"reading".to_string())
        })
        .collect();
    assert_eq!(done_reading.len(), 2);
}

#[test]
fn test_rust_open_reading_task_body_contains_rustlings() {
    let els = parse_fixture(RUST_FILE);
    let open_reading = els
        .values()
        .find(|e| {
            matches!(e, Element::Task { data, .. } if data.is_open())
                && e.tags().contains(&"reading".to_string())
        })
        .unwrap();
    assert!(open_reading.body_str().contains("Rustlings"));
}

#[test]
fn test_rust_habit_tasks() {
    let els = parse_fixture(RUST_FILE);
    let habit_tasks: Vec<_> = els
        .values()
        .filter(|e| e.kind() == ElementKind::Task && e.tags().contains(&"habits".to_string()))
        .collect();
    assert_eq!(habit_tasks.len(), 3);
    let done_habits = habit_tasks
        .iter()
        .filter(|e| matches!(e, Element::Task { data, .. } if data.is_done()))
        .count();
    assert_eq!(done_habits, 2);
}

#[test]
fn test_rust_log_duration_90_minutes() {
    let els = parse_fixture(RUST_FILE);
    let log = els.values().find(|e| e.kind() == ElementKind::Log).unwrap();
    if let Element::Log { data, .. } = log {
        assert_eq!(data.duration_minutes(), Some(90)); // 1h30m
        assert_eq!(data.duration_str(), Some("1h30m".into()));
    }
}

#[test]
fn test_rust_all_tasks_have_human_refs() {
    let els = parse_fixture(RUST_FILE);
    let r = RefResolver::new(&els);
    for (epoch_ref, el) in &els {
        if el.kind() == ElementKind::Task {
            let human = r.to_human(epoch_ref);
            assert!(
                human.is_some(),
                "task {} should have a human ref",
                epoch_ref
            );
        }
    }
}

// ── Release fixture (20260220) ────────────────────────────────────────────────

#[test]
fn test_release_done_tasks() {
    let els = parse_fixture(RELEASE_FILE);
    let done: Vec<_> = els
        .values()
        .filter(|e| matches!(e, Element::Task { data, .. } if data.is_done()))
        .collect();
    assert_eq!(done.len(), 2);
}

#[test]
fn test_release_total_log_time_360_minutes() {
    let els = parse_fixture(RELEASE_FILE);
    let total: i64 = els
        .values()
        .filter_map(|e| {
            if let Element::Log { data, .. } = e {
                data.duration_minutes()
            } else {
                None
            }
        })
        .sum();
    assert_eq!(total, 360); // 4h + 2h
}

#[test]
fn test_release_nested_notes_in_retrospective() {
    let els = parse_fixture(RELEASE_FILE);
    let notes_count = els
        .values()
        .filter(|e| e.kind() == ElementKind::Note)
        .count();
    assert!(notes_count >= 3, "expected ≥ 3 notes in release day");
}

#[test]
fn test_release_has_open_v11_task() {
    let els = parse_fixture(RELEASE_FILE);
    let v11_task = els.values().find(|e| {
        matches!(e, Element::Task { data, .. } if data.is_open()) && e.body_str().contains("v1.1")
    });
    assert!(v11_task.is_some(), "expected an open task mentioning v1.1");
}

#[test]
fn test_release_tags_include_work_and_release() {
    let els = parse_fixture(RELEASE_FILE);
    let all_tags: Vec<String> = els
        .values()
        .filter(|e| !e.is_mps_group() && !e.is_unknown())
        .flat_map(|e| e.tags().to_vec())
        .collect();
    assert!(all_tags.contains(&"work".to_string()));
    assert!(all_tags.contains(&"release".to_string()));
}

// ── RefResolver correctness ───────────────────────────────────────────────────

#[test]
fn test_ref_resolver_roundtrip_on_all_fixtures() {
    for fixture in &[SPRINT_FILE, RUST_FILE, RELEASE_FILE] {
        let els = parse_fixture(fixture);
        let r = RefResolver::new(&els);
        for epoch_ref in els.keys() {
            if let Some(human) = r.to_human(epoch_ref) {
                let back = r.to_epoch(human).unwrap_or("none");
                assert_eq!(
                    back,
                    epoch_ref.as_str(),
                    "roundtrip failed for {}",
                    epoch_ref
                );
            }
        }
    }
}

#[test]
fn test_ref_resolver_skips_unknown_elements() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    std::fs::write(&path, "@custom_type{ body }\n@task{ real task }").unwrap();
    let els = parser::parse_file(&path).unwrap();
    let r = RefResolver::new(&els);

    // Unknown element at .1 should be skipped
    assert!(r.to_human("20260101.1").is_none());
    // Task at .2 should be task-1 (counter resets per-type, Unknown doesn't consume)
    assert_eq!(r.to_human("20260101.2"), Some("task-1"));
}

// ── Store integration across multiple fixture files ───────────────────────────

#[test]
fn test_store_all_files_sorted() {
    let (_dir, store) = fixture_store();
    let files = store.all_files().unwrap();
    let names: Vec<String> = files
        .iter()
        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
        .collect();
    let mut sorted = names.clone();
    sorted.sort();
    assert_eq!(names, sorted, "files should be sorted chronologically");
}

#[test]
fn test_store_tag_counts_across_fixtures() {
    let (_dir, store) = fixture_store();
    let mut all_tags: HashMap<String, usize> = HashMap::new();
    for file in store.all_files().unwrap() {
        let date_str = file.file_name().unwrap().to_str().unwrap()[..8].to_string();
        let d = NaiveDate::parse_from_str(&date_str, "%Y%m%d").unwrap();
        let els = store.parse_date(d).unwrap();
        for el in els
            .values()
            .filter(|e| !e.is_mps_group() && !e.is_unknown())
        {
            for tag in el.tags() {
                *all_tags.entry(tag.clone()).or_insert(0) += 1;
            }
        }
    }
    assert!(
        all_tags.get("work").copied().unwrap_or(0) >= 3,
        "work tag appears across multiple files"
    );
    assert!(
        all_tags.get("reading").copied().unwrap_or(0) >= 3,
        "reading tag from rust day"
    );
    assert!(
        all_tags.get("release").copied().unwrap_or(0) >= 2,
        "release tag from release day"
    );
}

#[test]
fn test_store_search_authentication_across_fixtures() {
    let (_dir, store) = fixture_store();
    let results = store.search("authentication", None, None, None).unwrap();
    assert!(
        !results.is_empty(),
        "should find 'authentication' in some fixture"
    );
    assert!(results.iter().all(|r| !r.element.is_unknown()));
}

#[test]
fn test_store_search_type_filter_log() {
    let (_dir, store) = fixture_store();
    let results = store.search("", None, Some("log"), None).unwrap();
    assert!(results.iter().all(|r| r.element.kind() == ElementKind::Log));
}

#[test]
fn test_store_search_excludes_mps_and_unknown() {
    let (_dir, store) = fixture_store();
    let results = store.search("", None, None, None).unwrap();
    for r in &results {
        assert!(
            !r.element.is_mps_group(),
            "search should not return @mps groups"
        );
        assert!(
            !r.element.is_unknown(),
            "search should not return Unknown elements"
        );
    }
}

#[test]
fn test_store_all_file_dates_unique_and_sorted() {
    let (_dir, store) = fixture_store();
    let dates = store.all_file_dates().unwrap();
    let mut sorted = dates.clone();
    sorted.sort();
    sorted.dedup();
    assert_eq!(dates, sorted, "dates should be unique and sorted");
}

#[test]
fn test_store_files_since_sprint_date() {
    let (_dir, store) = fixture_store();
    let since = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
    let files = store.files_since(since).unwrap();
    for f in &files {
        let date_str = &f.file_name().unwrap().to_str().unwrap()[..8];
        assert!(
            date_str >= "20260201",
            "file {} is before since date",
            date_str
        );
    }
    // Files before 2026-02-01 (the January fixtures) should be excluded
    let jan_files: Vec<_> = files
        .iter()
        .filter(|f| {
            f.file_name()
                .unwrap()
                .to_str()
                .unwrap()
                .starts_with("202601")
        })
        .collect();
    assert!(jan_files.is_empty(), "January fixtures should be excluded");
}

// ── Store::rewrite_element ────────────────────────────────────────────────────

#[test]
fn test_rewrite_element_marks_task_done() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    std::fs::write(&path, "@task[work]{\n  Ship it\n}\n").unwrap();

    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();

    let mut attrs = HashMap::new();
    attrs.insert("status".to_string(), "done".to_string());

    // Use human ref "task-1"
    let ok = store.rewrite_element("task-1", &attrs, date).unwrap();
    assert!(ok, "rewrite should succeed");

    let content = std::fs::read_to_string(&path).unwrap();
    assert!(
        content.contains("status: done"),
        "file should now contain status: done"
    );
    assert!(content.contains("work"), "tag 'work' should be preserved");
}

#[test]
fn test_rewrite_element_by_epoch_ref() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    std::fs::write(&path, "@task[work]{\n  Ship it\n}\n").unwrap();

    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();

    // Parse to get the epoch ref
    let els = store.parse_date(date).unwrap();
    let epoch_ref = els
        .keys()
        .find(|k| {
            let e = &els[*k];
            e.kind() == ElementKind::Task
        })
        .unwrap()
        .clone();

    let mut attrs = HashMap::new();
    attrs.insert("status".to_string(), "done".to_string());

    let ok = store.rewrite_element(&epoch_ref, &attrs, date).unwrap();
    assert!(ok);

    let content = std::fs::read_to_string(&path).unwrap();
    assert!(content.contains("status: done"));
}

#[test]
fn test_rewrite_element_nonexistent_ref_returns_false() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    std::fs::write(&path, "@task{\n  Only task\n}\n").unwrap();

    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();

    let mut attrs = HashMap::new();
    attrs.insert("status".to_string(), "done".to_string());

    // task-99 doesn't exist
    let ok = store.rewrite_element("task-99", &attrs, date).unwrap();
    assert!(!ok, "should return false for missing ref");
}

#[test]
fn test_rewrite_element_preserves_file_content() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    let original = "@task[work]{\n  Task one\n}\n\n@note{\n  A note\n}\n";
    std::fs::write(&path, original).unwrap();

    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();

    let mut attrs = HashMap::new();
    attrs.insert("status".to_string(), "done".to_string());

    store.rewrite_element("task-1", &attrs, date).unwrap();

    let content = std::fs::read_to_string(&path).unwrap();
    // The note should be untouched
    assert!(content.contains("A note"), "note body should be preserved");
    // The task should have the updated status
    assert!(content.contains("status: done"));
}

#[test]
fn test_rewrite_element_log_start_end() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    std::fs::write(&path, "@log[work]{\n  Deep work session\n}\n").unwrap();

    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();

    let mut attrs = HashMap::new();
    attrs.insert("start".to_string(), "09:00".to_string());
    attrs.insert("end".to_string(), "12:00".to_string());

    let ok = store.rewrite_element("log-1", &attrs, date).unwrap();
    assert!(ok);

    let content = std::fs::read_to_string(&path).unwrap();
    assert!(content.contains("start: 09:00"));
    assert!(content.contains("end: 12:00"));
    assert!(content.contains("work"), "tag preserved");
}

// ── Store: epoch-less filenames (e.g. 20241103.mps) ──────────────────────────

#[test]
fn test_store_epoch_less_filename_included_in_all_files() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("20260101.1000000001.mps"), "@task{ A }").unwrap();
    std::fs::write(dir.path().join("20260102.mps"), "@note{ B }").unwrap();

    let store = Store::new(dir.path());
    let files = store.all_files().unwrap();
    let names: Vec<String> = files
        .iter()
        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
        .collect();

    assert!(names.contains(&"20260101.1000000001.mps".to_string()));
    assert!(names.contains(&"20260102.mps".to_string()));
}

#[test]
fn test_store_epoch_less_filename_found_by_find_file() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("20260101.mps"), "@task{ A }").unwrap();

    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
    assert!(store.find_file(date).is_some());
}

// ── Store: non-date files are ignored ────────────────────────────────────────

#[test]
fn test_store_non_date_filenames_ignored() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("20260101.1000000001.mps"), "@task{ A }").unwrap();
    std::fs::write(dir.path().join("weird_name.mps"), "@task{ B }").unwrap();

    let store = Store::new(dir.path());
    let files = store.all_files().unwrap();
    let names: Vec<String> = files
        .iter()
        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
        .collect();

    assert!(names.contains(&"20260101.1000000001.mps".to_string()));
    assert!(!names.contains(&"weird_name.mps".to_string()));
}

// ── Search edge cases ─────────────────────────────────────────────────────────

#[test]
fn test_search_empty_query_returns_all_non_mps() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join("20260101.1000000001.mps"),
        "@task{ do something }\n@note{ remember this }\n",
    )
    .unwrap();

    let store = Store::new(dir.path());
    // Empty query string should still work (matches everything)
    let results = store.search("", None, None, None).unwrap();
    assert_eq!(results.len(), 2);
}

// ── Parser: unknown element type ──────────────────────────────────────────────

#[test]
fn test_parser_unknown_element_in_search() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join("20260101.1000000001.mps"),
        "@custom_widget{ some body }\n@task{ real task }",
    )
    .unwrap();

    let store = Store::new(dir.path());
    let results = store.search("", None, None, None).unwrap();
    // Unknown element should be excluded; only the task should appear
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].element.kind(), ElementKind::Task);
}

// ── Date parsing ──────────────────────────────────────────────────────────────

#[test]
fn test_date_parse_all_formats() {
    use chrono::Local;
    use mps::date_parse::parse_date;

    let today = Local::now().date_naive();

    assert_eq!(parse_date("today").unwrap(), today);
    assert_eq!(
        parse_date("yesterday").unwrap(),
        today - chrono::Duration::days(1)
    );
    assert_eq!(
        parse_date("last week").unwrap(),
        today - chrono::Duration::days(7)
    );
    assert_eq!(
        parse_date("3 days ago").unwrap(),
        today - chrono::Duration::days(3)
    );
    assert_eq!(
        parse_date("20260101").unwrap(),
        NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
    );
    assert_eq!(
        parse_date("2026-01-01").unwrap(),
        NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
    );
    assert!(parse_date("not a date").is_err());
}

// ── RefResolver: deeper nesting ───────────────────────────────────────────────

#[test]
fn test_ref_resolver_three_levels_deep() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("20260101.1000000001.mps");
    std::fs::write(&path, "@mps{\n  @mps{\n    @task{ Deep }\n  }\n}").unwrap();

    let els = parser::parse_file(&path).unwrap();
    let r = RefResolver::new(&els);

    // @mps at depth 1 → mps-1
    // @mps at depth 2 → mps-1.1
    // @task at depth 3 → mps-1.1.1
    assert_eq!(r.to_human("20260101.1"), Some("mps-1"));
    assert_eq!(r.to_human("20260101.1.1"), Some("mps-1.1"));
    assert_eq!(r.to_human("20260101.1.1.1"), Some("mps-1.1.1"));
}

// ── Config ────────────────────────────────────────────────────────────────────

#[test]
fn test_config_loads_default_command_and_aliases() {
    use mps::config::Config;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join(".mps_config.yaml");

    // Write a YAML that mirrors what the Ruby gem generates (symbol keys)
    std::fs::write(
        &path,
        r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
git_remote: origin
git_branch: master
default_command: list
aliases:
  t: task
  n: note
"#,
    )
    .unwrap();

    let cfg = Config::load(&path).unwrap();
    assert_eq!(cfg.default_command, "list");
    assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
    assert_eq!(cfg.type_aliases.get("n").map(|s| s.as_str()), Some("note"));
}

#[test]
fn test_config_defaults_when_fields_absent() {
    use mps::config::Config;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join(".mps_config.yaml");

    // Minimal YAML — omit optional fields
    std::fs::write(
        &path,
        r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
"#,
    )
    .unwrap();

    let cfg = Config::load(&path).unwrap();
    assert_eq!(cfg.default_command, "open");
    assert!(cfg.type_aliases.is_empty());
    assert_eq!(cfg.git_remote, "origin");
    assert_eq!(cfg.git_branch, "master");
}

#[test]
fn test_config_loads_ruby_symbol_keys() {
    use mps::config::Config;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join(".mps_config.yaml");

    // Ruby-style YAML with symbol keys (leading colon)
    std::fs::write(
        &path,
        r#":mps_dir: /tmp/mps-test
:storage_dir: /tmp/mps-test/mps
:log_file: /tmp/mps-test/mps.log
:git_remote: origin
:git_branch: master
:default_command: list
"#,
    )
    .unwrap();

    let cfg = Config::load(&path).unwrap();
    assert_eq!(cfg.default_command, "list");
    assert_eq!(cfg.git_remote, "origin");
}

#[test]
fn test_config_loads_type_aliases_new_key() {
    use mps::config::Config;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join(".mps_config.yaml");

    std::fs::write(
        &path,
        r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
type_aliases:
  t: task
  c: character
"#,
    )
    .unwrap();

    let cfg = Config::load(&path).unwrap();
    assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
    assert_eq!(
        cfg.type_aliases.get("c").map(|s| s.as_str()),
        Some("character")
    );
}

#[test]
fn test_config_loads_command_aliases() {
    use mps::config::Config;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join(".mps_config.yaml");

    std::fs::write(
        &path,
        r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
command_aliases:
  a: append
  "+": append
  l: list
"#,
    )
    .unwrap();

    let cfg = Config::load(&path).unwrap();
    assert_eq!(
        cfg.command_aliases.get("a").map(|s| s.as_str()),
        Some("append")
    );
    assert_eq!(
        cfg.command_aliases.get("+").map(|s| s.as_str()),
        Some("append")
    );
    assert_eq!(
        cfg.command_aliases.get("l").map(|s| s.as_str()),
        Some("list")
    );
}

#[test]
fn test_config_aliases_backward_compat() {
    use mps::config::Config;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join(".mps_config.yaml");

    // Old configs used 'aliases:' key — must still load into type_aliases
    std::fs::write(
        &path,
        r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
aliases:
  t: task
  n: note
  r: reminder
"#,
    )
    .unwrap();

    let cfg = Config::load(&path).unwrap();
    assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
    assert_eq!(cfg.type_aliases.get("n").map(|s| s.as_str()), Some("note"));
    assert_eq!(
        cfg.type_aliases.get("r").map(|s| s.as_str()),
        Some("reminder")
    );
    assert!(cfg.command_aliases.is_empty());
}

// ── character element ────────────────────────────────────────────────────────

#[test]
fn test_character_append_and_parse() {
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append(
            "character",
            "He has done me a great favor",
            &["mahfuz-vai".to_string(), "favor".to_string()],
            &[("name", "Mahfuz Vai")],
            date,
        )
        .unwrap();
    let els = store.parse_date(date).unwrap();
    let el = els
        .values()
        .find(|e| e.kind() == ElementKind::Character)
        .expect("character element should exist");
    if let Element::Character { data, .. } = el {
        assert_eq!(data.name.as_deref(), Some("Mahfuz Vai"));
        assert!(data.tags.contains(&"favor".to_string()));
    }
    assert!(el.body_str().contains("great favor"));
}

#[test]
fn test_character_append_no_name() {
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append("character", "Anonymous observation", &[], &[], date)
        .unwrap();
    let els = store.parse_date(date).unwrap();
    let el = els
        .values()
        .find(|e| e.kind() == ElementKind::Character)
        .expect("character element should exist");
    if let Element::Character { data, .. } = el {
        assert!(data.name.is_none());
    }
}

#[test]
fn test_character_type_filter_excludes_task() {
    use mps::commands::{visible, DisplayOpts};
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append("task", "do the thing", &["work".to_string()], &[], date)
        .unwrap();
    store
        .append(
            "character",
            "person entry",
            &[],
            &[("name", "Someone")],
            date,
        )
        .unwrap();
    let els = store.parse_date(date).unwrap();

    let opts = DisplayOpts {
        type_filter: Some("character".to_string()),
        tag_filter: None,
        status_filter: None,
        name_filter: None,
    };
    let visible_kinds: Vec<_> = els
        .values()
        .filter(|e| !e.is_mps_group() && !e.is_unknown() && visible(e, &opts))
        .map(|e| e.kind())
        .collect();
    assert!(
        visible_kinds.iter().all(|k| *k == ElementKind::Character),
        "only character elements should be visible with type=character"
    );
    assert!(!visible_kinds.is_empty());
}

#[test]
fn test_character_tag_filter() {
    use mps::commands::{visible, DisplayOpts};
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append(
            "character",
            "entry one",
            &["mahfuz-vai".to_string()],
            &[("name", "Mahfuz Vai")],
            date,
        )
        .unwrap();
    store
        .append(
            "character",
            "entry two",
            &["other-person".to_string()],
            &[("name", "Other")],
            date,
        )
        .unwrap();
    let els = store.parse_date(date).unwrap();

    let opts = DisplayOpts {
        type_filter: None,
        tag_filter: Some("mahfuz-vai".to_string()),
        status_filter: None,
        name_filter: None,
    };
    let matched: Vec<_> = els
        .values()
        .filter(|e| !e.is_mps_group() && !e.is_unknown() && visible(e, &opts))
        .collect();
    assert_eq!(matched.len(), 1);
    assert_eq!(matched[0].kind(), ElementKind::Character);
}

#[test]
fn test_character_name_filter() {
    use mps::commands::{visible, DisplayOpts};
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append(
            "character",
            "about mahfuz",
            &["mahfuz-vai".to_string()],
            &[("name", "Mahfuz Vai")],
            date,
        )
        .unwrap();
    store
        .append(
            "character",
            "about alice",
            &["alice".to_string()],
            &[("name", "Alice")],
            date,
        )
        .unwrap();
    let els = store.parse_date(date).unwrap();

    let opts = DisplayOpts {
        type_filter: None,
        tag_filter: None,
        status_filter: None,
        name_filter: Some("Mahfuz Vai".to_string()),
    };
    let matched: Vec<_> = els
        .values()
        .filter(|e| !e.is_mps_group() && !e.is_unknown() && visible(e, &opts))
        .collect();
    assert_eq!(matched.len(), 1);
    if let Element::Character { data, .. } = matched[0] {
        assert_eq!(data.name.as_deref(), Some("Mahfuz Vai"));
    }
}

#[test]
fn test_character_search_by_body() {
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append(
            "character",
            "He offered me a fancy breakfast",
            &[],
            &[("name", "Mahfuz Vai")],
            date,
        )
        .unwrap();
    let results = store.search("fancy breakfast", None, None, None).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].element.kind(), ElementKind::Character);
}

#[test]
fn test_character_search_type_filter() {
    // Use a fresh store (no fixtures) so search results are fully predictable.
    let dir = tempfile::tempdir().unwrap();
    let store = Store::new(dir.path());
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store.append("note", "favor note", &[], &[], date).unwrap();
    store
        .append(
            "character",
            "favor character",
            &[],
            &[("name", "Mahfuz Vai")],
            date,
        )
        .unwrap();
    let results = store
        .search("favor", Some("character"), None, None)
        .unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].element.kind(), ElementKind::Character);
}

#[test]
fn test_character_counted_in_store_parse() {
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append("character", "first", &[], &[("name", "A")], date)
        .unwrap();
    store
        .append("character", "second", &[], &[("name", "B")], date)
        .unwrap();
    store.append("task", "a task", &[], &[], date).unwrap();
    let els = store.parse_date(date).unwrap();
    let char_count = els
        .values()
        .filter(|e| e.kind() == ElementKind::Character)
        .count();
    assert_eq!(char_count, 2);
    let task_count = els
        .values()
        .filter(|e| e.kind() == ElementKind::Task)
        .count();
    assert_eq!(task_count, 1);
}

#[test]
fn test_character_not_excluded_from_search() {
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append(
            "character",
            "unique phrase xyz",
            &[],
            &[("name", "Test")],
            date,
        )
        .unwrap();
    let results = store.search("unique phrase xyz", None, None, None).unwrap();
    assert_eq!(results.len(), 1);
}

#[test]
fn test_character_export_name_in_typed_attrs() {
    let (_dir, store) = fixture_store();
    let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
    store
        .append(
            "character",
            "body text",
            &[],
            &[("name", "Export Test")],
            date,
        )
        .unwrap();
    let els = store.parse_date(date).unwrap();
    let el = els
        .values()
        .find(|e| e.kind() == ElementKind::Character)
        .unwrap();
    let attrs = el.typed_attrs();
    let name_attr = attrs.iter().find(|(k, _)| k == "name");
    assert!(name_attr.is_some());
    assert_eq!(name_attr.unwrap().1, "Export Test");
}

// ── Meta + config merge ───────────────────────────────────────────────────────

#[test]
fn test_meta_shared_path_is_in_storage_dir() {
    use mps::meta::MetaShared;
    let dir = tempfile::tempdir().unwrap();
    let path = MetaShared::path(dir.path());
    assert_eq!(path, dir.path().join(".mps.meta"));
}

#[test]
fn test_meta_local_path_is_in_storage_dir() {
    use mps::meta::MetaLocal;
    let dir = tempfile::tempdir().unwrap();
    let path = MetaLocal::path(dir.path());
    assert_eq!(path, dir.path().join(".mps.local"));
}

#[test]
fn test_meta_shared_roundtrip_with_all_fields() {
    use mps::meta::MetaShared;
    use std::collections::HashMap;

    let dir = tempfile::tempdir().unwrap();
    let mut m = MetaShared::default();
    m.version = 1;
    m.config.type_aliases = HashMap::from([("t".into(), "task".into())]);
    m.config.command_aliases = HashMap::from([("a".into(), "append".into())]);
    m.config.default_command = Some("list".into());
    m.config.custom_tags = vec!["work".into(), "urgent".into()];
    m.config.notify.window_minutes = 10;
    m.config.notify.task_notify_at = Some("8am".into());
    m.save(dir.path()).unwrap();

    let m2 = MetaShared::load(dir.path());
    assert_eq!(m2.version, 1);
    assert_eq!(
        m2.config.type_aliases.get("t").map(|s| s.as_str()),
        Some("task")
    );
    assert_eq!(
        m2.config.command_aliases.get("a").map(|s| s.as_str()),
        Some("append")
    );
    assert_eq!(m2.config.default_command.as_deref(), Some("list"));
    assert_eq!(m2.config.custom_tags, vec!["work", "urgent"]);
    assert_eq!(m2.config.notify.window_minutes, 10);
    assert_eq!(m2.config.notify.task_notify_at.as_deref(), Some("8am"));
}

#[test]
fn test_config_merge_meta_unions_type_aliases() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;
    use std::collections::HashMap;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::from([("t".into(), "task".into())]),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(),
        serve: Default::default(),
    };

    let mut meta = MetaConfig::default();
    meta.type_aliases.insert("c".into(), "character".into());
    meta.type_aliases.insert("t".into(), "note".into()); // conflict — YAML wins
    meta.command_aliases.insert("a".into(), "append".into());
    meta.default_command = Some("list".into());
    meta.custom_tags = vec!["work".into()];

    cfg.merge_meta(&meta);

    assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task")); // YAML wins
    assert_eq!(
        cfg.type_aliases.get("c").map(|s| s.as_str()),
        Some("character")
    ); // meta adds
    assert_eq!(
        cfg.command_aliases.get("a").map(|s| s.as_str()),
        Some("append")
    );
    assert_eq!(cfg.default_command, "list"); // meta wins
    assert!(cfg.custom_tags.contains(&"work".to_string()));
}

#[test]
fn test_config_merge_meta_does_not_clobber_paths() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;
    use std::collections::HashMap;

    let dir = tempfile::tempdir().unwrap();
    let storage = dir.path().join("mps");
    std::fs::create_dir_all(&storage).unwrap();

    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: storage.clone(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(),
        serve: Default::default(),
    };

    let meta = MetaConfig::default();
    cfg.merge_meta(&meta);

    // Machine-specific paths must survive the merge unchanged.
    assert_eq!(cfg.storage_dir, storage);
    assert_eq!(cfg.git_remote, "origin");
    assert_eq!(cfg.git_branch, "master");
}

#[test]
fn test_config_merge_meta_custom_tags_deduped() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;
    use std::collections::HashMap;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec!["work".into()],
        notify: NotifyConfig::default(),
        serve: Default::default(),
    };

    let mut meta = MetaConfig::default();
    meta.custom_tags = vec!["work".into(), "urgent".into()]; // "work" duplicate

    cfg.merge_meta(&meta);

    let work_count = cfg
        .custom_tags
        .iter()
        .filter(|t| t.as_str() == "work")
        .count();
    assert_eq!(work_count, 1, "duplicate tags must be removed");
    assert!(cfg.custom_tags.contains(&"urgent".to_string()));
}

// ── Daemon unit file template substitution ────────────────────────────────────

#[test]
fn test_daemon_service_template_has_placeholders() {
    // The service template is private — verify behaviour indirectly by checking
    // that the install path writes a non-empty file (we cannot call install in tests
    // without a real systemd, so we test the template constants via the source).
    // Instead, assert the expected substrings are present in the embedded strings.
    // We do this by checking that `mps daemon run` doesn't panic on unknown subcommand.
    use mps::config::{Config, NotifyConfig};
    use std::collections::HashMap;

    let dir = tempfile::tempdir().unwrap();
    let storage = dir.path().join("mps");
    std::fs::create_dir_all(&storage).unwrap();
    // Create a dummy log file so ensure_dirs-style checks don't fail.
    std::fs::write(dir.path().join("mps.log"), "").unwrap();

    let cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: storage,
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(),
        serve: Default::default(),
    };

    // "run" subcommand should execute notify::run which does nothing when disabled.
    let mut cfg2 = cfg.clone();
    cfg2.notify.enabled = false;
    let result = mps::commands::daemon::run(&cfg2, "run");
    assert!(result.is_ok());

    // Unknown subcommand returns an error.
    let err = mps::commands::daemon::run(&cfg, "bogus");
    assert!(err.is_err());
}

// ── time_parse integration ────────────────────────────────────────────────────

#[test]
fn test_time_parse_used_in_notify_reminder_scan() {
    use chrono::Timelike;
    use mps::time_parse::parse_time;

    // Verify the time parser handles all formats that appear in real reminder fixtures.
    let cases = [
        ("5pm", 17, 0),
        ("9am", 9, 0),
        ("3:30pm", 15, 30),
        ("noon", 12, 0),
    ];
    for (input, exp_h, exp_m) in cases {
        let t = parse_time(input).unwrap_or_else(|_| panic!("parse_time('{}') failed", input));
        assert_eq!(t.hour(), exp_h, "hour mismatch for '{}'", input);
        assert_eq!(t.minute(), exp_m, "minute mismatch for '{}'", input);
    }
}

// ── notify dry-run (no network/display needed) ────────────────────────────────

#[test]
fn test_notify_dry_run_no_events_when_no_reminders() {
    use mps::config::{Config, NotifyConfig};
    use std::collections::HashMap;

    let dir = tempfile::tempdir().unwrap();
    let storage = dir.path().join("mps");
    std::fs::create_dir_all(&storage).unwrap();
    let log = dir.path().join("mps.log");
    std::fs::write(&log, "").unwrap();

    let cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: storage,
        log_file: log,
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(),
        serve: Default::default(),
    };

    // dry_run=true means no actual notify-send; no .mps.local written.
    let result = mps::commands::notify::run(&cfg, true, None, false);
    assert!(result.is_ok());
    // .mps.local must not be written in dry-run mode.
    assert!(!mps::meta::MetaLocal::path(&cfg.storage_dir).exists());
}

// ── Iteration 8: notify cooldown uses task_cooldown_minutes ──────────────────

/// Helper to build a minimal Config pointing to a temp storage dir.
fn make_notify_cfg(storage: &std::path::Path, log: &std::path::Path) -> mps::config::Config {
    use mps::config::NotifyConfig;
    mps::config::Config {
        mps_dir: storage.parent().unwrap().to_path_buf(),
        storage_dir: storage.to_path_buf(),
        log_file: log.to_path_buf(),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(),
        serve: Default::default(),
    }
}

#[test]
fn test_notify_cooldown_uses_task_cooldown_minutes() {
    // Verify that a reminder that was marked-notified within task_cooldown_minutes
    // is suppressed on the next run (dedup check).
    let dir = tempfile::tempdir().unwrap();
    let storage = dir.path().join("mps");
    std::fs::create_dir_all(&storage).unwrap();
    let log = dir.path().join("mps.log");
    std::fs::write(&log, "").unwrap();

    let cfg = make_notify_cfg(&storage, &log);

    // Pre-populate .mps.local: mark a fake epoch_ref as notified just now.
    let epoch_ref = "20260101.1700000000.1";
    let mut local = mps::meta::MetaLocal::default();
    local.mark_notified(epoch_ref);
    local.save(&storage).unwrap();

    // The cooldown default is 60 min; was_notified(60*60) must return true.
    let local2 = mps::meta::MetaLocal::load(&storage);
    assert!(
        local2.was_notified(epoch_ref, (cfg.notify.task_cooldown_minutes * 60) as i64),
        "reminder marked now must be within 60-minute cooldown"
    );
    // But the old window_minutes window (5 min = 300s) would also be fresh here —
    // confirm task_cooldown_minutes (3600s) is larger than window_minutes (300s).
    assert!(
        cfg.notify.task_cooldown_minutes * 60 > cfg.notify.window_minutes * 60,
        "cooldown must exceed window so dedup logic is meaningful"
    );
}

// ── Iteration 9: notify task list truncation — tested via internal unit test ──
// The NotifyEvent type is private; truncation is verified in src/commands/notify.rs tests.

// ── notify_open_tasks merge semantics ────────────────────────────────────────

#[test]
fn test_merge_meta_notify_open_tasks_false_in_meta_wins() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(), // notify_open_tasks=true
        serve: Default::default(),
    };

    let mut meta = MetaConfig::default();
    meta.notify.notify_open_tasks = false;

    cfg.merge_meta(&meta);
    assert!(
        !cfg.notify.notify_open_tasks,
        "meta.notify_open_tasks=false must disable open task briefing"
    );
}

#[test]
fn test_merge_meta_open_task_tags_from_meta() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(), // open_task_tags empty
        serve: Default::default(),
    };

    let mut meta = MetaConfig::default();
    meta.notify.open_task_tags = vec!["urgent".into(), "work".into()];

    cfg.merge_meta(&meta);
    assert_eq!(
        cfg.notify.open_task_tags,
        vec!["urgent", "work"],
        "meta open_task_tags must be applied when non-empty"
    );
}

#[test]
fn test_merge_meta_yaml_open_task_tags_not_clobbered_by_empty_meta() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig {
            open_task_tags: vec!["personal".into()],
            ..NotifyConfig::default()
        },
        serve: Default::default(),
    };

    // Meta has empty open_task_tags (default).
    let meta = MetaConfig::default();
    cfg.merge_meta(&meta);

    // YAML's tags must survive when meta has empty list.
    assert_eq!(
        cfg.notify.open_task_tags,
        vec!["personal"],
        "YAML open_task_tags must survive when meta is empty"
    );
}

// ── Iteration 10: field-by-field notify merge — YAML window survives ──────────

#[test]
fn test_merge_meta_notify_yaml_window_minutes_survives() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig {
            window_minutes: 10, // YAML sets a non-default window
            ..NotifyConfig::default()
        },
        serve: Default::default(),
    };

    // Meta only sets task_notify_at (non-default), nothing else.
    let mut meta = MetaConfig::default();
    meta.notify.task_notify_at = Some("9am".into());

    cfg.merge_meta(&meta);

    // window_minutes from YAML must survive — meta's value is the default (5).
    assert_eq!(
        cfg.notify.window_minutes, 10,
        "YAML window_minutes must survive when meta has default value"
    );
    // task_notify_at from meta must be applied.
    assert_eq!(
        cfg.notify.task_notify_at.as_deref(),
        Some("9am"),
        "meta task_notify_at must be merged"
    );
}

#[test]
fn test_merge_meta_notify_disabled_in_meta_wins() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(), // enabled=true
        serve: Default::default(),
    };

    let mut meta = MetaConfig::default();
    meta.notify.enabled = false; // meta turns off notifications

    cfg.merge_meta(&meta);

    assert!(
        !cfg.notify.enabled,
        "meta.notify.enabled=false must override YAML enabled=true"
    );
}

#[test]
fn test_merge_meta_notify_overdue_days_from_meta() {
    use mps::config::{Config, NotifyConfig};
    use mps::meta::MetaConfig;

    let dir = tempfile::tempdir().unwrap();
    let mut cfg = Config {
        mps_dir: dir.path().to_path_buf(),
        storage_dir: dir.path().to_path_buf(),
        log_file: dir.path().join("mps.log"),
        git_remote: "origin".into(),
        git_branch: "master".into(),
        default_command: "open".into(),
        type_aliases: HashMap::new(),
        command_aliases: HashMap::new(),
        custom_tags: vec![],
        notify: NotifyConfig::default(), // overdue_days=7
        serve: Default::default(),
    };

    let mut meta = MetaConfig::default();
    meta.notify.overdue_days = 14; // meta wants 2 weeks

    cfg.merge_meta(&meta);

    assert_eq!(
        cfg.notify.overdue_days, 14,
        "non-default meta overdue_days must override YAML default"
    );
}

// ── Iteration 15: open_task_tags filter ──────────────────────────────────────

#[test]
fn test_notify_enabled_false_returns_immediately() {
    let dir = tempfile::tempdir().unwrap();
    let storage = dir.path().join("mps");
    std::fs::create_dir_all(&storage).unwrap();
    let log = dir.path().join("mps.log");
    std::fs::write(&log, "").unwrap();

    let mut cfg = make_notify_cfg(&storage, &log);
    cfg.notify.enabled = false;

    // Append a task so there would be something to notify about if enabled.
    let store = mps::store::Store::new(&storage);
    let today = chrono::Local::now().date_naive();
    store
        .append("task", "Important task", &[], &[], today)
        .unwrap();

    // Should succeed quietly with no notifications.
    let result = mps::commands::notify::run(&cfg, true, None, false);
    assert!(result.is_ok());
}

#[test]
fn test_notify_open_tasks_tag_filter_excludes_untagged() {
    let dir = tempfile::tempdir().unwrap();
    let storage = dir.path().join("mps");
    std::fs::create_dir_all(&storage).unwrap();
    let log = dir.path().join("mps.log");
    std::fs::write(&log, "").unwrap();

    let mut cfg = make_notify_cfg(&storage, &log);
    // Only tasks tagged "urgent" should appear.
    cfg.notify.open_task_tags = vec!["urgent".into()];
    // Set task_notify_at to "now" so the briefing fires (within window).
    // Since we use dry_run, we just verify the function succeeds.
    cfg.notify.task_notify_at = Some("9am".into());
    cfg.notify.notify_open_tasks = true;

    let store = mps::store::Store::new(&storage);
    let today = chrono::Local::now().date_naive();
    // Append a task without the "urgent" tag.
    store
        .append("task", "Non-urgent task", &[], &[], today)
        .unwrap();

    let result = mps::commands::notify::run(&cfg, true, None, false);
    assert!(result.is_ok(), "notify with tag filter must not error");
}
