//! File-system layer — discovers, reads, and writes `.mps` files.
//!
//! The CLI delegates all I/O to [`Store`]; no direct file operations happen
//! in command handlers.

use crate::constants::{mps_file_name_regexp, new_file_name, MPS_EXT};
use crate::elements::Element;
use crate::error::MpsError;
use crate::parser;
use crate::ref_resolver::RefResolver;
use chrono::NaiveDate;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[allow(dead_code)]
pub struct SearchResult {
    pub element: Element,
    pub file: PathBuf,
    pub date_str: String, // "YYYYMMDD"
    pub epoch_ref: String,
}

pub struct Store {
    storage_dir: PathBuf,
}

impl Store {
    pub fn new(storage_dir: impl Into<PathBuf>) -> Self {
        Store {
            storage_dir: storage_dir.into(),
        }
    }

    /// First .mps file matching date, or None.
    pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
        self.find_files(date).into_iter().next()
    }

    /// All .mps files matching date (handles multiple files per day).
    pub fn find_files(&self, date: NaiveDate) -> Vec<PathBuf> {
        let prefix = date.format("%Y%m%d").to_string();
        let re = mps_file_name_regexp();
        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)
            .map(|rd| {
                rd.filter_map(|e| e.ok())
                    .map(|e| e.path())
                    .filter(|p| {
                        p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
                            && p.file_name()
                                .and_then(|n| n.to_str())
                                .map(|n| re.is_match(n) && n.starts_with(&prefix))
                                .unwrap_or(false)
                    })
                    .collect()
            })
            .unwrap_or_default();
        files.sort();
        files
    }

    /// Existing file for date, or a generated new path (file not yet created).
    pub fn find_or_create_path(&self, date: NaiveDate) -> PathBuf {
        self.find_file(date)
            .unwrap_or_else(|| self.storage_dir.join(new_file_name(date)))
    }

    /// Parsed elements for date. Returns empty map if no file exists.
    pub fn parse_date(&self, date: NaiveDate) -> Result<IndexMap<String, Element>, MpsError> {
        match self.find_file(date) {
            None => Ok(IndexMap::new()),
            Some(p) => parser::parse_file(&p),
        }
    }

    /// Append a new element to date's file (creates file if absent).
    pub fn append(
        &self,
        kind: &str,
        body: &str,
        tags: &[String],
        attrs: &[(&str, &str)],
        date: NaiveDate,
    ) -> Result<PathBuf, MpsError> {
        let mut parts: Vec<String> = attrs.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
        parts.extend(tags.iter().cloned());
        let args_str = parts.join(", ");

        let path = self.find_or_create_path(date);
        let chunk = format!("\n@{}[{}]{{\n  {}\n}}\n", kind, args_str, body);
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)?;
        f.write_all(chunk.as_bytes())?;
        Ok(path)
    }

    /// All .mps files in storage_dir, sorted by filename (chronological).
    pub fn all_files(&self) -> Result<Vec<PathBuf>, MpsError> {
        let re = mps_file_name_regexp();
        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)?
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| {
                p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
                    && p.file_name()
                        .and_then(|n| n.to_str())
                        .map(|n| re.is_match(n))
                        .unwrap_or(false)
            })
            .collect();
        files.sort();
        Ok(files)
    }

    /// Files whose date-stamp >= since_date.
    pub fn files_since(&self, since_date: NaiveDate) -> Result<Vec<PathBuf>, MpsError> {
        let since_str = since_date.format("%Y%m%d").to_string();
        let files = self
            .all_files()?
            .into_iter()
            .filter(|p| {
                p.file_name()
                    .and_then(|n| n.to_str())
                    .map(|n| &n[..8] >= since_str.as_str())
                    .unwrap_or(false)
            })
            .collect();
        Ok(files)
    }

    /// Unique sorted dates for which .mps files exist.
    pub fn all_file_dates(&self) -> Result<Vec<NaiveDate>, MpsError> {
        let mut seen = std::collections::HashSet::new();
        let mut dates: Vec<NaiveDate> = self
            .all_files()?
            .iter()
            .filter_map(|p| {
                p.file_name()
                    .and_then(|n| n.to_str())
                    .and_then(|n| NaiveDate::parse_from_str(&n[..8], "%Y%m%d").ok())
            })
            .filter(|d| seen.insert(*d))
            .collect();
        dates.sort();
        Ok(dates)
    }

    /// Rewrite an element's typed attributes in-place, atomically.
    ///
    /// `ref_str` may be an epoch ref (e.g. "20260428.1") or a human ref (e.g. "task-1").
    /// Human refs are resolved against `date` (defaults to today in the caller).
    /// `new_attrs` maps attribute name → new value (e.g. {"status" → "done"}).
    ///
    /// Returns `true` on success, `false` if ref not found or file unchanged.
    pub fn rewrite_element(
        &self,
        ref_str: &str,
        new_attrs: &HashMap<String, String>,
        date: NaiveDate,
    ) -> Result<bool, MpsError> {
        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
        let (epoch_ref, path) = match (epoch_ref, path) {
            (Some(e), Some(p)) => (e, p),
            _ => return Ok(false),
        };

        let elements = match cached {
            Some(els) => els,
            None => parser::parse_file(&path)?,
        };
        let el = match elements.get(&epoch_ref) {
            Some(e) => e.clone(),
            None => return Ok(false),
        };
        if el.is_unknown() {
            return Ok(false);
        }

        self.rewrite_element_in_file(&path, &el, &epoch_ref, &elements, new_attrs)
    }

    // ── private helpers ──────────────────────────────────────────────────────

    /// Resolve a ref string to (epoch_ref, file_path, maybe_elements).
    ///
    /// For human refs the file must be parsed to look up the epoch ref; the parsed
    /// elements are returned as the third field so callers can reuse them instead of
    /// parsing a second time.  For epoch refs no parse is needed, so `None` is returned
    /// and the caller should parse when needed.
    fn resolve_ref_with_elements(
        &self,
        ref_str: &str,
        date: NaiveDate,
    ) -> Result<
        (
            Option<String>,
            Option<PathBuf>,
            Option<IndexMap<String, Element>>,
        ),
        MpsError,
    > {
        // Epoch ref: 8 ASCII digits followed by '.' then a digit
        let is_epoch = ref_str.len() >= 10
            && ref_str[..8].chars().all(|c| c.is_ascii_digit())
            && ref_str.chars().nth(8) == Some('.')
            && ref_str
                .chars()
                .nth(9)
                .map(|c| c.is_ascii_digit())
                .unwrap_or(false);

        if is_epoch {
            let d = NaiveDate::parse_from_str(&ref_str[..8], "%Y%m%d")
                .map_err(|_| MpsError::DateParseError(ref_str[..8].to_string()))?;
            let path = self.find_file(d);
            Ok((Some(ref_str.to_string()), path, None))
        } else {
            let path = match self.find_file(date) {
                Some(p) => p,
                None => return Ok((None, None, None)),
            };
            let elements = parser::parse_file(&path)?;
            let resolver = RefResolver::new(&elements);
            let epoch_ref = resolver.to_epoch(ref_str).map(|s| s.to_string());
            Ok((epoch_ref, Some(path), Some(elements)))
        }
    }

    // ── Public mutating operations ───────────────────────────────────────────

    /// Delete an element entirely from its file.
    /// Returns `true` if the element was found and removed, `false` if not found.
    pub fn delete_element(&self, ref_str: &str, date: NaiveDate) -> Result<bool, MpsError> {
        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
        let (epoch_ref, path) = match (epoch_ref, path) {
            (Some(e), Some(p)) => (e, p),
            _ => return Ok(false),
        };
        let content = std::fs::read_to_string(&path)?;
        let elements = match cached {
            Some(els) => els,
            None => parser::parse_file(&path)?,
        };
        let el = match elements.get(&epoch_ref) {
            Some(e) => e.clone(),
            None => return Ok(false),
        };
        if el.is_unknown() {
            return Ok(false);
        }

        let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
        let (start, end) = match find_element_span(&content, el.sign(), el.raw_args(), occ) {
            Some(s) => s,
            None => return Ok(false),
        };

        let new_content = format!("{}{}", &content[..start], &content[end..]);
        atomic_write(&path, &new_content)?;
        Ok(true)
    }

    /// Extract the body text of an element (the content between its `{` and `}`).
    /// Returns `None` if the element is not found.
    pub fn extract_element_body(
        &self,
        ref_str: &str,
        date: NaiveDate,
    ) -> Result<Option<String>, MpsError> {
        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
        let (epoch_ref, path) = match (epoch_ref, path) {
            (Some(e), Some(p)) => (e, p),
            _ => return Ok(None),
        };
        let content = std::fs::read_to_string(&path)?;
        let elements = match cached {
            Some(els) => els,
            None => parser::parse_file(&path)?,
        };
        let el = match elements.get(&epoch_ref) {
            Some(e) => e.clone(),
            None => return Ok(None),
        };
        if el.is_unknown() {
            return Ok(None);
        }

        let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
        let body = extract_body_text(&content, el.sign(), el.raw_args(), occ);
        Ok(body)
    }

    /// Replace an element's body text in-place.
    /// `new_body` should NOT include the surrounding `{` / `}` braces.
    pub fn replace_element_body(
        &self,
        ref_str: &str,
        new_body: &str,
        date: NaiveDate,
    ) -> Result<bool, MpsError> {
        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
        let (epoch_ref, path) = match (epoch_ref, path) {
            (Some(e), Some(p)) => (e, p),
            _ => return Ok(false),
        };
        let content = std::fs::read_to_string(&path)?;
        let elements = match cached {
            Some(els) => els,
            None => parser::parse_file(&path)?,
        };
        let el = match elements.get(&epoch_ref) {
            Some(e) => e.clone(),
            None => return Ok(false),
        };
        if el.is_unknown() {
            return Ok(false);
        }

        let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
        let new_content = replace_body_text(&content, el.sign(), el.raw_args(), occ, new_body);
        match new_content {
            Some(nc) if nc != content => {
                atomic_write(&path, &nc)?;
                Ok(true)
            }
            _ => Ok(false),
        }
    }

    // ── private helpers ──────────────────────────────────────────────────────

    /// 0-indexed count of elements with the same (sign, raw_args) that appear
    /// before `epoch_ref` in file order. Used to disambiguate duplicate openers.
    fn occurrence_of(
        &self,
        epoch_ref: &str,
        type_name: &str,
        raw: &str,
        all_elements: &IndexMap<String, Element>,
    ) -> usize {
        let mut sorted_keys: Vec<&String> = all_elements.keys().collect();
        sorted_keys.sort_by(|a, b| {
            let ap: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
            let bp: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
            ap.cmp(&bp)
        });
        let mut occurrence = 0usize;
        for key in &sorted_keys {
            if *key == epoch_ref {
                break;
            }
            if !key.contains('.') {
                continue;
            }
            if let Some(other) = all_elements.get(*key) {
                if other.sign() == type_name && other.raw_args() == raw {
                    occurrence += 1;
                }
            }
        }
        occurrence
    }

    /// Rewrite the `@type[args]{` opening line in-place and save atomically.
    fn rewrite_element_in_file(
        &self,
        path: &Path,
        el: &Element,
        epoch_ref: &str,
        all_elements: &IndexMap<String, Element>,
        new_attrs: &HashMap<String, String>,
    ) -> Result<bool, MpsError> {
        let content = std::fs::read_to_string(path)?;
        let type_name = el.sign();
        let raw = el.raw_args();

        // Merge new attrs over existing typed attrs (preserve order; append new keys at end).
        let mut merged: Vec<(String, String)> = el.typed_attrs();
        for (k, v) in new_attrs {
            if let Some(pos) = merged.iter().position(|(ek, _)| ek == k) {
                merged[pos].1 = v.clone();
            } else {
                merged.push((k.clone(), v.clone()));
            }
        }

        // Build new args string: named attrs first, then tags.
        let attr_parts: Vec<String> = merged
            .iter()
            .filter(|(_, v)| !v.is_empty())
            .map(|(k, v)| format!("{}: {}", k, v))
            .collect();
        let new_args_str: String = attr_parts
            .into_iter()
            .chain(el.tags().iter().cloned())
            .collect::<Vec<_>>()
            .join(", ");

        // Build a regex matching the current element opening line.
        let esc_type = regex::escape(type_name);
        let old_pat = if raw.is_empty() {
            format!(r"@{}(?:\[\])?\s*\{{", esc_type)
        } else {
            format!(r"@{}\[{}\]\s*\{{", esc_type, regex::escape(raw))
        };
        let re = regex::Regex::new(&old_pat).map_err(|e| MpsError::ParseError {
            file: path.display().to_string(),
            msg: e.to_string(),
        })?;

        let occurrence = self.occurrence_of(epoch_ref, type_name, raw, all_elements);

        // Replace specifically the (occurrence)-th match (0-indexed).
        let new_open = format!("@{}[{}]{{", type_name, new_args_str);
        let new_content = re
            .find_iter(&content)
            .enumerate()
            .find(|(n, _)| *n == occurrence)
            .map(|(_, m)| {
                format!(
                    "{}{}{}",
                    &content[..m.start()],
                    new_open,
                    &content[m.end()..]
                )
            });
        let new_content: Option<String> = new_content;

        let new_content = match new_content {
            Some(c) => c,
            None => return Ok(false),
        };
        if new_content == content {
            return Ok(false);
        }

        atomic_write(path, &new_content)?;
        Ok(true)
    }

    /// Full-text search across files. Returns matched elements with file and date_str.
    pub fn search(
        &self,
        query: &str,
        type_filter: Option<&str>,
        tag_filter: Option<&str>,
        since_date: Option<NaiveDate>,
    ) -> Result<Vec<SearchResult>, MpsError> {
        let files = match since_date {
            Some(d) => self.files_since(d)?,
            None => self.all_files()?,
        };

        let query_lower = query.to_lowercase();
        let mut results = Vec::new();

        for file in files {
            let date_str = file
                .file_name()
                .and_then(|n| n.to_str())
                .map(|n| n[..8].to_string())
                .unwrap_or_default();

            let elements = parser::parse_file(&file)?;

            for (epoch_ref, el) in elements {
                if el.is_mps_group() || el.is_unknown() {
                    continue;
                }

                if let Some(tf) = type_filter {
                    if el.sign() != tf {
                        continue;
                    }
                }
                if let Some(tag) = tag_filter {
                    if !el.tags().iter().any(|t| t == tag) {
                        continue;
                    }
                }
                if !el.body_str().to_lowercase().contains(&query_lower) {
                    continue;
                }

                results.push(SearchResult {
                    element: el,
                    epoch_ref: epoch_ref.clone(),
                    file: file.clone(),
                    date_str: date_str.clone(),
                });
            }
        }

        Ok(results)
    }
}

// ── File-level helpers ────────────────────────────────────────────────────────

/// Atomic write: write to tmp then rename (POSIX-atomic).
fn atomic_write(path: &Path, content: &str) -> Result<(), MpsError> {
    let tmp = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id()));
    std::fs::write(&tmp, content)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

/// Build the opening-line regex pattern for `@type[raw]{`.
fn opener_pattern(type_name: &str, raw: &str) -> String {
    let esc = regex::escape(type_name);
    if raw.is_empty() {
        format!(r"@{}(?:\[\])?\s*\{{", esc)
    } else {
        format!(r"@{}\[{}\]\s*\{{", esc, regex::escape(raw))
    }
}

/// Find the byte range `(line_start, after_close_newline)` of the `occurrence`-th
/// element with signature `(type_name, raw)` in `content`.
/// The range covers the entire element: opening line, body, closing `}` and its newline.
fn find_element_span(
    content: &str,
    type_name: &str,
    raw: &str,
    occurrence: usize,
) -> Option<(usize, usize)> {
    let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;

    // Find the nth occurrence of the opening pattern.
    let m = re.find_iter(content).nth(occurrence)?;

    // Start of the full line containing the opener.
    let line_start = content[..m.start()].rfind('\n').map(|p| p + 1).unwrap_or(0);

    // Walk forward from the `{` counting brace depth to find the matching `}`.
    // m.end() points to the char after `{`, so the `{` is at m.end()-1.
    let brace_start = m.end() - 1;
    let mut depth = 0i32;
    let mut close_end = None;
    for (i, c) in content[brace_start..].char_indices() {
        match c {
            '{' => depth += 1,
            '}' => {
                depth -= 1;
                if depth == 0 {
                    close_end = Some(brace_start + i + 1); // byte after `}`
                    break;
                }
            }
            _ => {}
        }
    }
    let end_byte = close_end?;

    // Include the newline that follows the closing `}`, if present.
    let after_newline = if content[end_byte..].starts_with('\n') {
        end_byte + 1
    } else {
        end_byte
    };

    Some((line_start, after_newline))
}

/// Strip the minimum common leading whitespace from all non-empty lines.
fn dedent(s: &str) -> String {
    let min_indent = s
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.len() - l.trim_start().len())
        .min()
        .unwrap_or(0);
    s.lines()
        .map(|l| {
            if l.len() >= min_indent {
                &l[min_indent..]
            } else {
                l.trim_start()
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// Extract the body text (the content between `{` and `}`) for the given element.
/// Leading/trailing blank lines are stripped; common indentation is removed (dedented)
/// so the editor sees clean unindented content.
fn extract_body_text(
    content: &str,
    type_name: &str,
    raw: &str,
    occurrence: usize,
) -> Option<String> {
    let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
    let m = re.find_iter(content).nth(occurrence)?;

    // Body starts right after the `{` (end of the match).
    let body_start = m.end();
    // Find matching `}`.
    let brace_start = m.end() - 1;
    let mut depth = 0i32;
    let mut close_pos = None;
    for (i, c) in content[brace_start..].char_indices() {
        match c {
            '{' => depth += 1,
            '}' => {
                depth -= 1;
                if depth == 0 {
                    close_pos = Some(brace_start + i);
                    break;
                }
            }
            _ => {}
        }
    }
    let close = close_pos?;
    let raw_body = content[body_start..close].trim_matches('\n');
    // Dedent so the editor shows "Fix the bug" not "  Fix the bug".
    Some(dedent(raw_body))
}

/// Replace the body text of the `occurrence`-th element.
/// Returns the new full file content, or `None` if the element was not found.
fn replace_body_text(
    content: &str,
    type_name: &str,
    raw: &str,
    occurrence: usize,
    new_body: &str,
) -> Option<String> {
    let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
    let m = re.find_iter(content).nth(occurrence)?;

    let body_start = m.end();
    let brace_start = m.end() - 1;
    let mut depth = 0i32;
    let mut close_pos = None;
    for (i, c) in content[brace_start..].char_indices() {
        match c {
            '{' => depth += 1,
            '}' => {
                depth -= 1;
                if depth == 0 {
                    close_pos = Some(brace_start + i);
                    break;
                }
            }
            _ => {}
        }
    }
    let close = close_pos?;

    // Preserve indentation of the closing `}` line.
    let close_line_start = content[..close].rfind('\n').map(|p| p + 1).unwrap_or(0);
    let close_indent: String = content[close_line_start..close]
        .chars()
        .take_while(|c| c.is_whitespace())
        .collect();

    // Re-indent new_body to match the element's indentation.
    let body_indent = &close_indent;
    let indented_body: String = new_body
        .lines()
        .map(|line| {
            if line.trim().is_empty() {
                String::new()
            } else {
                format!("{}  {}", body_indent, line.trim())
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    Some(format!(
        "{}\n{}\n{}{}",
        &content[..body_start], // everything up to and including `{`
        indented_body,
        close_indent,
        &content[close..], // `}` and everything after
    ))
}

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

    fn make_store(dir: &Path) -> Store {
        Store::new(dir)
    }

    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn test_find_file_absent() {
        let dir = tempfile::tempdir().unwrap();
        let store = make_store(dir.path());
        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        assert!(store.find_file(date).is_none());
    }

    #[test]
    fn test_find_file_present() {
        let dir = tempfile::tempdir().unwrap();
        write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
        let store = make_store(dir.path());
        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        assert!(store.find_file(date).is_some());
    }

    #[test]
    fn test_parse_date_empty() {
        let dir = tempfile::tempdir().unwrap();
        let store = make_store(dir.path());
        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        let els = store.parse_date(date).unwrap();
        assert!(els.is_empty());
    }

    #[test]
    fn test_append_creates_file() {
        let dir = tempfile::tempdir().unwrap();
        let store = make_store(dir.path());
        let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
        let path = store
            .append("task", "Do a thing", &["work".into()], &[], date)
            .unwrap();
        assert!(path.exists());

        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("@task"));
        assert!(content.contains("Do a thing"));
    }

    #[test]
    fn test_append_then_parse() {
        let dir = tempfile::tempdir().unwrap();
        let store = make_store(dir.path());
        let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
        store
            .append("task", "Test task", &["work".into()], &[], date)
            .unwrap();
        let els = store.parse_date(date).unwrap();
        // root mps + task
        assert!(els.len() >= 2);
        let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
        assert!(has_task);
    }

    #[test]
    fn test_search_by_query() {
        let dir = tempfile::tempdir().unwrap();
        write_file(
            dir.path(),
            "20260101.1700000000.mps",
            "@task{ auth token fix }",
        );
        let store = make_store(dir.path());
        let results = store.search("auth", None, None, None).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].date_str, "20260101");
    }

    #[test]
    fn test_files_since() {
        let dir = tempfile::tempdir().unwrap();
        write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
        write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
        let store = make_store(dir.path());
        let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
        let files = store.files_since(since).unwrap();
        assert_eq!(files.len(), 1);
        assert!(files[0].to_str().unwrap().contains("20260601"));
    }

    // ── delete_element ────────────────────────────────────────────────────────

    #[test]
    fn test_delete_element_removes_it() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
        let store = make_store(dir.path());
        store.append("task", "Delete me", &[], &[], date).unwrap();
        let els = store.parse_date(date).unwrap();
        let epoch_ref = els
            .keys()
            .find(|k| k.contains('.') && els[*k].sign() == "task")
            .unwrap()
            .clone();
        let removed = store.delete_element(&epoch_ref, date).unwrap();
        assert!(removed);
        let els2 = store.parse_date(date).unwrap();
        assert!(!els2.values().any(|e| e.sign() == "task"));
    }

    #[test]
    fn test_delete_element_absent_returns_false() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
        let store = make_store(dir.path());
        write_file(dir.path(), "20260601.1700000000.mps", "@note{ hi }");
        let removed = store
            .delete_element("20260601.1700000000.999", date)
            .unwrap();
        assert!(!removed);
    }

    #[test]
    fn test_delete_element_file_still_valid_after() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
        let store = make_store(dir.path());
        store.append("task", "Keep me", &[], &[], date).unwrap();
        store.append("note", "Also kept", &[], &[], date).unwrap();
        store.append("task", "Delete me", &[], &[], date).unwrap();

        let els = store.parse_date(date).unwrap();
        let to_delete = els
            .keys()
            .filter(|k| k.contains('.') && els[*k].sign() == "task")
            .last()
            .unwrap()
            .clone();

        store.delete_element(&to_delete, date).unwrap();

        let els2 = store.parse_date(date).unwrap();
        let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
        assert_eq!(tasks.len(), 1);
        assert!(tasks[0].body_str().contains("Keep me"));
        assert!(els2.values().any(|e| e.sign() == "note"));
    }

    // ── extract_element_body / replace_element_body ───────────────────────────

    #[test]
    fn test_extract_element_body_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
        let store = make_store(dir.path());
        store
            .append("note", "Original body text", &[], &[], date)
            .unwrap();

        let els = store.parse_date(date).unwrap();
        let epoch_ref = els
            .keys()
            .find(|k| k.contains('.') && els[*k].sign() == "note")
            .unwrap()
            .clone();

        let body = store
            .extract_element_body(&epoch_ref, date)
            .unwrap()
            .unwrap();
        assert!(body.contains("Original body text"));
    }

    #[test]
    fn test_replace_element_body_writes_new_text() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
        let store = make_store(dir.path());
        store.append("note", "Old text", &[], &[], date).unwrap();

        let els = store.parse_date(date).unwrap();
        let epoch_ref = els
            .keys()
            .find(|k| k.contains('.') && els[*k].sign() == "note")
            .unwrap()
            .clone();

        let changed = store
            .replace_element_body(&epoch_ref, "New text", date)
            .unwrap();
        assert!(changed);

        let els2 = store.parse_date(date).unwrap();
        let note = els2.values().find(|e| e.sign() == "note").unwrap();
        assert!(note.body_str().contains("New text"));
        assert!(!note.body_str().contains("Old text"));
    }

    #[test]
    fn test_replace_element_body_same_content_returns_false() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
        let store = make_store(dir.path());
        store.append("note", "Same text", &[], &[], date).unwrap();

        let els = store.parse_date(date).unwrap();
        let epoch_ref = els
            .keys()
            .find(|k| k.contains('.') && els[*k].sign() == "note")
            .unwrap()
            .clone();

        let body = store
            .extract_element_body(&epoch_ref, date)
            .unwrap()
            .unwrap();
        let changed = store.replace_element_body(&epoch_ref, &body, date).unwrap();
        assert!(!changed, "no-op write should return false");
    }

    // ── find_element_span / extract_body_text / replace_body_text ────────────

    #[test]
    fn test_find_element_span_basic() {
        let content = "@task[work]{\n  Fix the bug\n}\n";
        let (start, end) = find_element_span(content, "task", "work", 0).unwrap();
        assert_eq!(start, 0);
        assert_eq!(&content[start..end], "@task[work]{\n  Fix the bug\n}\n");
    }

    #[test]
    fn test_find_element_span_second_occurrence() {
        let content = "@note{\n  first\n}\n@note{\n  second\n}\n";
        let (s1, e1) = find_element_span(content, "note", "", 0).unwrap();
        let (s2, e2) = find_element_span(content, "note", "", 1).unwrap();
        assert!(s1 < s2);
        assert!(&content[s1..e1].contains("first"));
        assert!(&content[s2..e2].contains("second"));
    }

    #[test]
    fn test_extract_body_text_basic() {
        let content = "@task[work]{\n  Fix the bug\n}\n";
        let body = extract_body_text(content, "task", "work", 0).unwrap();
        assert_eq!(body.trim(), "Fix the bug");
    }

    #[test]
    fn test_replace_body_text_basic() {
        let content = "@task[work]{\n  Fix the bug\n}\n";
        let new = replace_body_text(content, "task", "work", 0, "Replaced body").unwrap();
        assert!(new.contains("Replaced body"));
        assert!(!new.contains("Fix the bug"));
        assert!(new.contains("@task[work]{"));
        assert!(new.contains('}'));
    }

    #[test]
    fn test_replace_body_text_multiline() {
        let content = "@note{\n  line one\n  line two\n}\n";
        let new = replace_body_text(
            content,
            "note",
            "",
            0,
            "new line one\nnew line two\nnew line three",
        )
        .unwrap();
        assert!(new.contains("new line one"));
        assert!(new.contains("new line three"));
        assert!(!new.contains("line one\n  line two"));
    }

    // ── Iteration 1: dedent() edge cases ─────────────────────────────────────

    #[test]
    fn test_dedent_already_clean() {
        assert_eq!(dedent("Fix the bug"), "Fix the bug");
    }

    #[test]
    fn test_dedent_strips_common_indent() {
        let s = "  line one\n  line two";
        assert_eq!(dedent(s), "line one\nline two");
    }

    #[test]
    fn test_dedent_preserves_relative_indent() {
        // All lines have 2 spaces but "  nested" has 4 — relative gap preserved.
        let s = "  outer\n    nested";
        assert_eq!(dedent(s), "outer\n  nested");
    }

    #[test]
    fn test_dedent_ignores_empty_lines_for_min() {
        // Empty line should not set min_indent to 0 and ruin dedent.
        let s = "  line one\n\n  line two";
        assert_eq!(dedent(s), "line one\n\nline two");
    }

    #[test]
    fn test_dedent_empty_string() {
        assert_eq!(dedent(""), "");
    }

    #[test]
    fn test_dedent_all_blank_lines() {
        // min_indent falls back to 0 — output unchanged.
        let s = "\n\n";
        assert_eq!(dedent(s), "\n");
    }

    // ── Iteration 2: extract_body_text with deep indent ───────────────────────

    #[test]
    fn test_extract_body_text_dedents_for_editor() {
        // File has 2-space indented body; editor should see unindented text.
        let content = "@task[work]{\n  Fix the bug\n  and test it\n}\n";
        let body = extract_body_text(content, "task", "work", 0).unwrap();
        assert_eq!(body, "Fix the bug\nand test it");
    }

    #[test]
    fn test_extract_body_text_empty_body() {
        let content = "@note{\n}\n";
        let body = extract_body_text(content, "note", "", 0).unwrap();
        assert_eq!(body, "");
    }

    #[test]
    fn test_extract_body_text_single_line_no_indent() {
        let content = "@note{ quick note }\n";
        let body = extract_body_text(content, "note", "", 0).unwrap();
        assert_eq!(body.trim(), "quick note");
    }

    // ── Iteration 3: extract_body_text second occurrence ─────────────────────

    #[test]
    fn test_extract_body_text_second_occurrence() {
        let content = "@note{\n  first note\n}\n@note{\n  second note\n}\n";
        let body1 = extract_body_text(content, "note", "", 0).unwrap();
        let body2 = extract_body_text(content, "note", "", 1).unwrap();
        assert_eq!(body1.trim(), "first note");
        assert_eq!(body2.trim(), "second note");
    }

    // ── Iteration 4: replace_body_text with nested braces in body ─────────────

    #[test]
    fn test_replace_body_text_nested_braces_in_body() {
        // Body contains `{` and `}` — brace-counting must not confuse them with the closer.
        let content = "@note{\n  code: { x: 1 }\n}\n";
        let new = replace_body_text(content, "note", "", 0, "simple replacement").unwrap();
        assert!(new.contains("simple replacement"));
        assert!(!new.contains("code: { x: 1 }"));
        assert!(new.contains("@note{"));
        // The closing `}` must still be there.
        assert!(new.ends_with("}\n") || new.ends_with('}'));
    }

    // ── Iteration 5: replace_body_text only replaces correct occurrence ────────

    #[test]
    fn test_replace_body_text_second_occurrence_only() {
        let content = "@note{\n  keep this\n}\n@note{\n  replace this\n}\n";
        let new = replace_body_text(content, "note", "", 1, "replaced").unwrap();
        assert!(new.contains("keep this"), "first note must be untouched");
        assert!(new.contains("replaced"), "second note must be updated");
        assert!(
            !new.contains("replace this"),
            "old text of second note gone"
        );
    }

    // ── Iteration 6: find_element_span with nested element inside sprint ──────

    #[test]
    fn test_find_element_span_nested_does_not_confuse_brace_count() {
        // A sprint block contains a task — outer span must include everything.
        let content = "@mps[sprint]{\n  @task[work]{\n    Do something\n  }\n}\n";
        let (start, end) = find_element_span(content, "mps", "sprint", 0).unwrap();
        let span = &content[start..end];
        assert!(span.contains("@task"), "span must include nested task");
        assert!(span.starts_with("@mps"));
    }

    // ── Iteration 7: find_element_span absent returns None ────────────────────

    #[test]
    fn test_find_element_span_absent_returns_none() {
        let content = "@note{\n  hi\n}\n";
        assert!(find_element_span(content, "task", "", 0).is_none());
        assert!(find_element_span(content, "note", "", 1).is_none()); // only 1 note
    }

    // ── Bonus edge cases ──────────────────────────────────────────────────────

    #[test]
    fn test_replace_body_text_empty_new_body() {
        let content = "@note{\n  some text\n}\n";
        let new = replace_body_text(content, "note", "", 0, "").unwrap();
        // Must still contain a valid opener and closer.
        assert!(new.contains("@note{"));
        assert!(new.contains('}'));
        // Old text gone.
        assert!(!new.contains("some text"));
    }

    #[test]
    fn test_extract_body_text_tabs_are_preserved_after_dedent() {
        // A body with tab-indented lines: dedent strips spaces but not mixed tabs.
        // The minimum indent is 0 (tab has char value but len==1), so no dedent applied.
        let content = "@note{\n\ttab-indented\n}\n";
        let body = extract_body_text(content, "note", "", 0).unwrap();
        // Tab should still be present (dedent counts byte length, min_indent=1).
        // dedent removes 1 char from the front — the tab itself.
        assert!(body.contains("tab-indented"));
    }

    #[test]
    fn test_delete_element_second_of_two_same_type() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
        let store = make_store(dir.path());
        store.append("note", "Keep me", &[], &[], date).unwrap();
        store.append("note", "Delete me", &[], &[], date).unwrap();

        let els = store.parse_date(date).unwrap();
        let to_delete = els
            .iter()
            .filter(|(k, e)| {
                k.contains('.') && e.sign() == "note" && e.body_str().contains("Delete me")
            })
            .map(|(k, _)| k.clone())
            .next()
            .unwrap();

        let removed = store.delete_element(&to_delete, date).unwrap();
        assert!(removed);

        let els2 = store.parse_date(date).unwrap();
        let notes: Vec<_> = els2.values().filter(|e| e.sign() == "note").collect();
        assert_eq!(notes.len(), 1);
        assert!(
            notes[0].body_str().contains("Keep me"),
            "the wrong note was deleted"
        );
        assert!(!notes[0].body_str().contains("Delete me"));
    }

    #[test]
    fn test_extract_and_replace_preserves_other_elements() {
        let dir = tempfile::tempdir().unwrap();
        let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
        let store = make_store(dir.path());
        store.append("task", "Fix bug", &[], &[], date).unwrap();
        store.append("note", "Edit me", &[], &[], date).unwrap();
        store.append("task", "Write tests", &[], &[], date).unwrap();

        let els = store.parse_date(date).unwrap();
        let note_ref = els
            .iter()
            .find(|(k, e)| k.contains('.') && e.sign() == "note")
            .map(|(k, _)| k.clone())
            .unwrap();

        store
            .replace_element_body(&note_ref, "Updated note", date)
            .unwrap();

        let els2 = store.parse_date(date).unwrap();
        let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
        assert_eq!(tasks.len(), 2, "both tasks must survive the note edit");
        let note = els2.values().find(|e| e.sign() == "note").unwrap();
        assert!(note.body_str().contains("Updated note"));
    }
}
