//! In-place mutations of reflection/search_bookmarks.ncl.
//!
//! Mirrors qa_ncl.rs — line-level surgery on a predictable Nickel structure.
//! The bookmark store has a single `entries` array of `BookmarkEntry` records.

use std::path::Path;

/// Data for a new bookmark entry.
pub struct NewBookmark<'a> {
    pub node_id: &'a str,
    pub kind: &'a str,
    pub title: &'a str,
    pub level: &'a str,
    pub term: &'a str,
    pub actor: &'a str,
    pub created_at: &'a str,
    pub tags: &'a [String],
}

/// Append a new bookmark entry to reflection/search_bookmarks.ncl.
///
/// Returns the generated id (`sb-NNN`).
pub fn add_entry(path: &Path, entry: NewBookmark<'_>) -> anyhow::Result<String> {
    let content = std::fs::read_to_string(path)?;
    let next_id = next_entry_id(&content);

    let block = format!(
        r#"    {{
      id         = "{id}",
      node_id    = "{node_id}",
      kind       = "{kind}",
      title      = "{title}",
      level      = "{level}",
      term       = "{term}",
      actor      = "{actor}",
      created_at = "{created_at}",
      tags       = {tags},
    }},
"#,
        id = next_id,
        node_id = escape_ncl(entry.node_id),
        kind = escape_ncl(entry.kind),
        title = escape_ncl(entry.title),
        level = escape_ncl(entry.level),
        term = escape_ncl(entry.term),
        actor = escape_ncl(entry.actor),
        created_at = escape_ncl(entry.created_at),
        tags = ncl_string_array(entry.tags),
    );

    let updated = insert_before_entries_close(&content, &block)?;
    super::ncl_write::atomic_write(path, &updated)?;
    Ok(next_id)
}

/// Remove the bookmark entry block with `id`.
pub fn remove_entry(path: &Path, id: &str) -> anyhow::Result<()> {
    let content = std::fs::read_to_string(path)?;
    let updated = delete_entry_block(&content, id)?;
    super::ncl_write::atomic_write(path, &updated)?;
    Ok(())
}

// ── helpers ──────────────────────────────────────────────────────────────────

/// Find the highest `sb-NNN` id and return `sb-(NNN+1)` zero-padded to 3
/// digits.
fn next_entry_id(content: &str) -> String {
    let max = content
        .lines()
        .filter_map(|line| {
            let t = line.trim();
            let rest = t.strip_prefix("id")?;
            let val = rest.split('"').nth(1)?;
            let num_str = val.strip_prefix("sb-")?;
            num_str.parse::<u32>().ok()
        })
        .max()
        .unwrap_or(0);
    format!("sb-{:03}", max + 1)
}

/// Insert `block` before the closing `  ],` of the entries array.
fn insert_before_entries_close(content: &str, block: &str) -> anyhow::Result<String> {
    let needle = "  ],";
    let pos = content.find(needle).ok_or_else(|| {
        anyhow::anyhow!("could not locate entries array closing `  ],` in search_bookmarks.ncl")
    })?;
    let mut result = String::with_capacity(content.len() + block.len());
    result.push_str(&content[..pos]);
    result.push_str(block);
    result.push_str(&content[pos..]);
    Ok(result)
}

/// Remove the block containing `id = "sb-NNN"`.
fn delete_entry_block(content: &str, id: &str) -> anyhow::Result<String> {
    let id_needle = format!("\"{}\"", id);
    let lines: Vec<&str> = content.lines().collect();
    let n = lines.len();

    let id_line = lines
        .iter()
        .position(|l| l.contains(&id_needle) && l.contains('='))
        .ok_or_else(|| anyhow::anyhow!("entry id {} not found in search_bookmarks.ncl", id))?;

    let block_start = (0..=id_line)
        .rev()
        .find(|&i| lines[i].trim() == "{")
        .ok_or_else(|| anyhow::anyhow!("could not find block open for bookmark entry {}", id))?;

    let block_end = (id_line..n)
        .find(|&i| lines[i].trim() == "},")
        .ok_or_else(|| anyhow::anyhow!("could not find block close for bookmark entry {}", id))?;

    let mut result = Vec::with_capacity(n - (block_end - block_start + 1));
    for (i, line) in lines.iter().enumerate() {
        if i < block_start || i > block_end {
            result.push(*line);
        }
    }
    Ok(result.join("\n"))
}

fn ncl_string_array(items: &[String]) -> String {
    if items.is_empty() {
        return "[]".to_string();
    }
    let inner: Vec<String> = items
        .iter()
        .map(|s| format!("\"{}\"", escape_ncl(s)))
        .collect();
    format!("[{}]", inner.join(", "))
}

fn escape_ncl(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

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

    const SAMPLE: &str = concat!(
        "let s = import \"search_bookmarks\" in\n",
        "{\n",
        "  entries = [\n",
        "    {\n",
        "      id         = \"sb-001\",\n",
        "      node_id    = \"add-project\",\n",
        "      kind       = \"node\",\n",
        "      title      = \"Add a project\",\n",
        "      level      = \"Practice\",\n",
        "      term       = \"add project\",\n",
        "      actor      = \"developer\",\n",
        "      created_at = \"2026-03-14\",\n",
        "      tags       = [],\n",
        "    },\n",
        "    {\n",
        "      id         = \"sb-002\",\n",
        "      node_id    = \"ontology-axiom\",\n",
        "      kind       = \"node\",\n",
        "      title      = \"Ontology axiom\",\n",
        "      level      = \"Axiom\",\n",
        "      term       = \"axiom\",\n",
        "      actor      = \"developer\",\n",
        "      created_at = \"2026-03-14\",\n",
        "      tags       = [],\n",
        "    },\n",
        "  ],\n",
        "} | s.BookmarkStore\n",
    );

    #[test]
    fn next_id_empty() {
        assert_eq!(next_entry_id(""), "sb-001");
    }

    #[test]
    fn next_id_increments() {
        let content = r#"id = "sb-007","#;
        assert_eq!(next_entry_id(content), "sb-008");
    }

    #[test]
    fn array_empty() {
        assert_eq!(ncl_string_array(&[]), "[]");
    }

    #[test]
    fn array_values() {
        let v = vec!["search".to_string(), "ontology".to_string()];
        assert_eq!(ncl_string_array(&v), r#"["search", "ontology"]"#);
    }

    #[test]
    fn insert_into_empty_store() {
        let content =
            "let s = import \"search_bookmarks\" in\n{\n  entries = [\n  ],\n} | s.BookmarkStore\n";
        let block = "    { id = \"sb-001\" },\n";
        let result = insert_before_entries_close(content, block).unwrap();
        assert!(result.contains("{ id = \"sb-001\" }"));
        assert!(result.contains("  ],"));
    }

    #[test]
    fn delete_first_entry() {
        let updated = delete_entry_block(SAMPLE, "sb-001").unwrap();
        assert!(!updated.contains("sb-001"), "sb-001 should be removed");
        assert!(updated.contains("sb-002"), "sb-002 should remain");
    }

    #[test]
    fn delete_second_entry() {
        let updated = delete_entry_block(SAMPLE, "sb-002").unwrap();
        assert!(updated.contains("sb-001"), "sb-001 should remain");
        assert!(!updated.contains("sb-002"), "sb-002 should be removed");
    }

    #[test]
    fn delete_missing_id_errors() {
        assert!(delete_entry_block(SAMPLE, "sb-999").is_err());
    }

    #[test]
    fn escape_quotes_and_backslashes() {
        assert_eq!(escape_ncl(r#"say "hi""#), r#"say \"hi\""#);
        assert_eq!(escape_ncl(r"path\to"), r"path\\to");
    }

    #[tokio::test]
    async fn concurrent_add_produces_unique_ids() {
        use std::path::PathBuf;
        use std::sync::Arc;

        use tempfile::NamedTempFile;

        const MINIMAL: &str =
            "let s = import \"search_bookmarks\" in\n{\n  entries = [\n  ],\n} | s.BookmarkStore\n";
        const TASKS: usize = 6;

        let lock = Arc::new(super::super::ncl_write::NclWriteLock::new());
        let file = NamedTempFile::new().unwrap();
        std::fs::write(file.path(), MINIMAL).unwrap();
        let path: Arc<PathBuf> = Arc::new(file.path().to_path_buf());

        let handles: Vec<_> = (0..TASKS)
            .map(|i| {
                let lock = Arc::clone(&lock);
                let path = Arc::clone(&path);
                tokio::spawn(async move {
                    let _guard = lock.acquire(&path).await;
                    add_entry(
                        &path,
                        NewBookmark {
                            node_id: &format!("node-{i}"),
                            kind: "node",
                            title: &format!("Title {i}"),
                            level: "Practice",
                            term: "search term",
                            actor: "developer",
                            created_at: "2026-03-14",
                            tags: &[],
                        },
                    )
                })
            })
            .collect();

        let mut ids: Vec<String> = {
            let mut v = Vec::with_capacity(TASKS);
            for h in handles {
                v.push(h.await.unwrap().unwrap());
            }
            v
        };
        ids.sort();
        ids.dedup();
        assert_eq!(
            ids.len(),
            TASKS,
            "concurrent add_entry must produce unique IDs"
        );
    }
}
