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

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

// ── 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 mps::date_parse::parse_date;
    use chrono::Local;

    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");
}
