//! # Module: notify
//!
//! ## Spec
//! - Appends a blockquote notification to a template document's exchange component.
//! - Notification format is a `> **[NOTIFY from <source>]** (<timestamp>)` blockquote.
//! - Appends before the boundary marker if one exists, otherwise at the end of the
//!   exchange component content.
//! - After appending, writes the document through document authority,
//!   checkpoints the typed baseline, and optionally commits.
//! - `--backlog-add` / `--backlog-add-gated` adds items to the document's
//!   `agent:backlog` component. Auto-creates the component if absent (after
//!   exchange close tag). Legacy aliases: `--pending-add` /
//!   `--pending-add-gated`.
//! - `--no-create-backlog` opts out of auto-creation.
//! - When only backlog ops are requested (no message), the exchange blockquote
//!   is skipped (backlog-only mode).
//! - Backlog errors become no-ops when `--no-create-backlog` is set and the
//!   component is absent.
//!
//! ## Agentic Contracts
//! - `run(file, message, source, affects, commit, pending_add, pending_add_gated, no_create)`
//!   — returns `Err` if the file is missing, message is required but absent, or the exchange
//!   component is not found (when a message is provided).
//! - Backlog-only mode (no message + backlog items): skips exchange, checkpoints the baseline, commits.
//! - The typed baseline is always updated after a successful write.
//! - When `commit` is true, calls `git::commit` after writing.
//!
//! ## Evals
//! - basic_notify: exchange component + message → blockquote appended with timestamp
//! - notify_with_source: `--source` provided → `[NOTIFY from <source>]` header
//! - notify_without_source: no source → `[NOTIFY]` header
//! - notify_with_affects: `--affects` provided → `Re-evaluate:` line present
//! - notify_without_affects: no affects → no `Re-evaluate:` line
//! - notify_before_boundary: boundary marker present → notification inserted before it
//! - multiline_message: message with newlines → each line prefixed with `> `
//! - backlog_add_auto_creates_component: no backlog component → auto-created, item added
//! - backlog_add_to_existing_component: existing backlog component → item added at the beginning
//! - backlog_add_gated_creates_gated_item: `--backlog-add-gated` → item with `[/]` state
//! - no_create_backlog_skips_when_absent: `--no-create-backlog` + absent → no-op
//! - backlog_add_with_message_writes_both: backlog + message → both written
//! - backlog_only_updates_baseline: backlog-only → typed baseline checkpointed
//! - auto_create_inserts_after_exchange: component placed after exchange close tag
//! - message_required_without_backlog: no message, no backlog → `Err`

use anyhow::{Context, Result, bail};
use std::path::Path;
use std::process::Command;

use agent_doc_element::element::{self, is_backlog_component};
use agent_doc_element_backlog::backlog;

/// Format an ISO-8601 timestamp using the system `date` command.
fn iso_timestamp() -> String {
    let output = Command::new("date")
        .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
        .output()
        .ok();
    match output {
        Some(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
        None => "unknown".to_string(),
    }
}

/// Format the notification blockquote.
fn format_notification(message: &str, source: Option<&str>, affects: Option<&str>) -> String {
    let timestamp = iso_timestamp();
    let header = match source {
        Some(s) => format!("> **[NOTIFY from {}]** ({})", s, timestamp),
        None => format!("> **[NOTIFY]** ({})", timestamp),
    };

    let mut lines = vec![String::new(), header];

    // Message lines, each prefixed with >
    for line in message.lines() {
        if line.is_empty() {
            lines.push(">".to_string());
        } else {
            lines.push(format!("> {}", line));
        }
    }

    if let Some(affects_text) = affects {
        lines.push(">".to_string());
        lines.push(format!("> **Re-evaluate:** {}", affects_text));
    }

    lines.push(String::new());
    lines.join("\n")
}

/// Ensure the document has an `agent:backlog` component; create one if absent.
/// Returns `Ok(true)` if a component was created, `Ok(false)` if it already existed.
/// Under `no_create`, returns `Err` if absent.
fn ensure_pending_component(file: &Path, no_create: bool) -> Result<bool> {
    let doc = agent_doc_document_realtime_io::try_resolve_current_document_content(
        file,
        "notify_command_document",
    )
    .with_context(|| format!("failed to resolve {}", file.display()))?;
    let components = element::parse(&doc)
        .with_context(|| format!("failed to parse components in {}", file.display()))?;

    if components.iter().any(|c| is_backlog_component(&c.name)) {
        return Ok(false);
    }

    if no_create {
        bail!("document has no backlog component and --no-create-backlog was set");
    }

    // Insert before ## Assistant / ## Pending / end-of-file, whichever comes first.
    // Prefer inserting before the exchange close tag if it exists, otherwise at end.
    let insert_pos = find_pending_insert_position(&doc, &components);

    let pending_block = "\n<!-- agent:backlog -->\n<!-- /agent:backlog -->\n";
    let mut new_doc = String::with_capacity(doc.len() + pending_block.len());
    new_doc.push_str(&doc[..insert_pos]);
    new_doc.push_str(pending_block);
    new_doc.push_str(&doc[insert_pos..]);

    agent_doc_document_realtime_io::atomic_write_through_authority(file, &new_doc)?;
    eprintln!(
        "[notify] auto-created agent:backlog component in {}",
        file.display()
    );
    Ok(true)
}

/// Find the best position to insert a backlog component.
fn find_pending_insert_position(doc: &str, components: &[element::Component]) -> usize {
    // After the exchange close tag if one exists
    if let Some(exchange) = components.iter().find(|c| c.name == "exchange") {
        return exchange.close_end;
    }
    // Otherwise at end of document
    doc.len()
}

/// Append a notification to a document's exchange component, update the snapshot,
/// and optionally commit. When backlog items are present, adds them to the
/// backlog component (auto-creating it if needed) and skips the exchange blockquote.
#[allow(clippy::too_many_arguments)]
pub fn run(
    file: &Path,
    message: Option<&str>,
    source: Option<&str>,
    affects: Option<&str>,
    commit: bool,
    pending_add: &[String],
    pending_add_gated: &[String],
    no_create_pending: bool,
) -> Result<()> {
    if !file.exists() {
        bail!("file not found: {}", file.display());
    }
    let has_pending_ops = !pending_add.is_empty() || !pending_add_gated.is_empty();
    let pending_only = has_pending_ops && message.is_none();

    // --- Backlog operations ---
    if has_pending_ops {
        // Ensure backlog component exists (auto-create unless opted out)
        match ensure_pending_component(file, no_create_pending) {
            Ok(_) => {}
            Err(e) => {
                // Under auto-create mode, errors become no-ops
                if no_create_pending {
                    eprintln!("[notify] skipping backlog ops: {}", e);
                    if pending_only {
                        return Ok(());
                    }
                } else {
                    return Err(e);
                }
            }
        }

        // Add backlog items
        let doc_id = agent_doc_hash::document_id_for_path(file);
        for item in pending_add.iter().rev() {
            match add_pending_item(file, item, &doc_id, false) {
                Ok(id) => eprintln!("[notify] added backlog item #{}", id),
                Err(e) => eprintln!("[notify] backlog-add no-op: {}", e),
            }
        }
        for item in pending_add_gated.iter().rev() {
            match add_pending_item(file, item, &doc_id, true) {
                Ok(id) => eprintln!("[notify] added gated backlog item #{}", id),
                Err(e) => eprintln!("[notify] backlog-add-gated no-op: {}", e),
            }
        }
    }

    // --- Exchange notification (skip in backlog-only mode) ---
    if pending_only {
        // Still update snapshot and commit
        let doc = agent_doc_document_realtime_io::try_resolve_current_document_content(
            file,
            "notify_command_document",
        )
        .with_context(|| format!("failed to resolve {}", file.display()))?;
        checkpoint_baseline(file, &doc)?;

        if commit {
            agent_doc_commit_io::commit(file)?;
        }
        return Ok(());
    }

    let message = message
        .ok_or_else(|| anyhow::anyhow!("message is required when --backlog-add is not used"))?;

    let doc = agent_doc_document_realtime_io::try_resolve_current_document_content(
        file,
        "notify_command_document",
    )
    .with_context(|| format!("failed to resolve {}", file.display()))?;

    let components = element::parse(&doc)
        .with_context(|| format!("failed to parse components in {}", file.display()))?;

    let exchange = components
        .iter()
        .find(|c| c.name == "exchange")
        .ok_or_else(|| anyhow::anyhow!("component 'exchange' not found in {}", file.display()))?;

    let notification = format_notification(message, source, affects);

    // Find the boundary marker if one exists inside the exchange component
    let content_region = &doc[exchange.open_end..exchange.close_start];
    let boundary_pos = find_boundary_position(content_region);

    let new_doc = if let Some(rel_pos) = boundary_pos {
        // Insert before the boundary marker line
        let abs_pos = exchange.open_end + rel_pos;
        // Find start of the line containing the boundary
        let line_start = doc[..abs_pos]
            .rfind('\n')
            .map(|i| i + 1)
            .unwrap_or(exchange.open_end)
            .max(exchange.open_end);

        let mut result = String::with_capacity(doc.len() + notification.len());
        result.push_str(&doc[..line_start]);
        result.push_str(&notification);
        if !notification.ends_with('\n') {
            result.push('\n');
        }
        result.push_str(&doc[line_start..]);
        result
    } else {
        // Append at the end of the exchange content
        let existing = exchange.content(&doc);
        let mut new_content = String::with_capacity(existing.len() + notification.len());
        new_content.push_str(existing);
        new_content.push_str(&notification);
        exchange.replace_content(&doc, &new_content)
    };

    agent_doc_document_realtime_io::atomic_write_through_authority(file, &new_doc)
        .with_context(|| format!("failed to write {}", file.display()))?;

    checkpoint_baseline(file, &new_doc)?;

    eprintln!(
        "Notified exchange in {} (source: {})",
        file.display(),
        source.unwrap_or("none")
    );

    if commit {
        agent_doc_commit_io::commit(file)?;
    }

    Ok(())
}

/// Add a single backlog item to a document's backlog component.
/// Returns the assigned hash ID on success.
fn add_pending_item(file: &Path, item: &str, doc_id: &str, gated: bool) -> Result<String> {
    let content = agent_doc_document_realtime_io::try_resolve_current_document_content(
        file,
        "notify_command_document",
    )
    .context("failed to resolve document")?;
    let components = element::parse(&content).context("failed to parse components")?;
    let comp = components
        .into_iter()
        .find(|c| is_backlog_component(&c.name))
        .context("document has no backlog/pending component")?;
    let existing = &content[comp.open_end..comp.close_start];
    let (new_content, id) = backlog::op_add(existing, item, doc_id, gated)?;
    let new_doc = comp.replace_content(&content, &new_content);
    agent_doc_document_realtime_io::atomic_write_through_authority(file, &new_doc)?;
    Ok(id)
}

/// Checkpoint normal baseline state in the typed ledger.
///
/// The checkpoint emits its write-only crash sidecar only as a downstream
/// effect after the typed fact commits; the sidecar is never the baseline.
fn checkpoint_baseline(file: &Path, doc: &str) -> Result<()> {
    agent_doc_snapshot_io::checkpoint_document_baseline(file, doc, agent_doc_ops_log_io::log_op)
        .with_context(|| format!("failed to checkpoint baseline for {}", file.display()))
}

/// Find the byte offset of a boundary marker within a content region.
/// Returns the relative offset from the start of the region.
fn find_boundary_position(content: &str) -> Option<usize> {
    // Boundary markers look like: <!-- agent:boundary:UUID -->
    let prefix = "<!-- agent:boundary:";
    content.find(prefix)
}

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

    fn setup_project() -> TempDir {
        let dir = TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
        dir
    }

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

    #[test]
    fn basic_notify() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nSome content\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("Hello world"),
            None,
            None,
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("> **[NOTIFY]**"));
        assert!(result.contains("> Hello world"));
        assert!(result.contains("<!-- agent:exchange"));
        assert!(result.contains("<!-- /agent:exchange -->"));
    }

    #[test]
    fn notify_with_source() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("Update available"),
            Some("build-monitor"),
            None,
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("> **[NOTIFY from build-monitor]**"));
    }

    #[test]
    fn notify_without_source() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("Something happened"),
            None,
            None,
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("> **[NOTIFY]**"));
        assert!(!result.contains("from"));
    }

    #[test]
    fn notify_with_affects() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("API changed"),
            None,
            Some("integration tests"),
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("> **Re-evaluate:** integration tests"));
    }

    #[test]
    fn notify_without_affects() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        run(&doc, Some("Just FYI"), None, None, false, &[], &[], false).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(!result.contains("Re-evaluate"));
    }

    #[test]
    fn notify_before_boundary() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nExisting content\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("Before boundary"),
            None,
            None,
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        let notify_pos = result.find("> Before boundary").unwrap();
        let boundary_pos = result.find("<!-- agent:boundary:abc123 -->").unwrap();
        assert!(
            notify_pos < boundary_pos,
            "notification should be before boundary"
        );
    }

    #[test]
    fn multiline_message() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("Line one\nLine two\nLine three"),
            None,
            None,
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("> Line one"));
        assert!(result.contains("> Line two"));
        assert!(result.contains("> Line three"));
    }

    #[test]
    fn typed_baseline_drives_crash_projection_after_notify() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        run(
            &doc,
            Some("Snapshot test"),
            None,
            None,
            false,
            &[],
            &[],
            false,
        )
        .unwrap();

        let baseline = agent_doc_snapshot_io::load_document_baseline(&doc)
            .unwrap()
            .expect("typed baseline");
        assert!(baseline.contains("> Snapshot test"));
        let crash_projection =
            std::fs::read_to_string(agent_doc_fs::snapshot_path_for(&doc).unwrap()).unwrap();
        assert_eq!(crash_projection, baseline);
    }

    // --- Pending-add tests ---

    #[test]
    fn pending_add_auto_creates_component() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n",
        );

        let items = vec!["implement feature X".to_string()];
        run(&doc, None, None, None, false, &items, &[], false).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("<!-- agent:backlog"));
        assert!(result.contains("<!-- /agent:backlog -->"));
        assert!(result.contains("implement feature X"));
        assert!(result.contains("[ ]"));
        assert!(result.contains("[#"));
        // Backlog-only: no exchange blockquote
        assert!(!result.contains("> **[NOTIFY"));
    }

    #[test]
    fn pending_add_to_existing_component() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n<!-- agent:pending -->\n- [ ] [#abcd] existing item\n<!-- /agent:pending -->\n",
        );

        let items = vec!["new item".to_string()];
        run(&doc, None, None, None, false, &items, &[], false).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        let pending = result
            .split("<!-- agent:pending -->\n")
            .nth(1)
            .and_then(|rest| rest.split("\n<!-- /agent:pending -->").next())
            .unwrap();
        let lines: Vec<&str> = pending.lines().collect();
        assert!(
            lines[0].contains("new item"),
            "expected new item first, got: {}",
            pending
        );
        assert!(
            lines[1].contains("existing item"),
            "expected existing item second, got: {}",
            pending
        );
        assert!(result.matches("[#").count() >= 2);
    }

    #[test]
    fn pending_add_multiple_items_keep_sequence_order_at_front() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n<!-- agent:pending -->\n- [ ] [#abcd] existing item\n<!-- /agent:pending -->\n",
        );

        let items = vec!["first new".to_string(), "second new".to_string()];
        run(&doc, None, None, None, false, &items, &[], false).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        let pending = result
            .split("<!-- agent:pending -->\n")
            .nth(1)
            .and_then(|rest| rest.split("\n<!-- /agent:pending -->").next())
            .unwrap();
        let lines: Vec<&str> = pending.lines().collect();
        assert!(
            lines[0].contains("first new"),
            "expected first new item first, got: {}",
            pending
        );
        assert!(
            lines[1].contains("second new"),
            "expected second new item second, got: {}",
            pending
        );
        assert!(
            lines[2].contains("existing item"),
            "expected existing item after new items, got: {}",
            pending
        );
    }

    #[test]
    fn pending_add_gated_creates_gated_item() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n",
        );

        let gated = vec!["gated feature".to_string()];
        run(&doc, None, None, None, false, &[], &gated, false).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        assert!(result.contains("[/]"));
        assert!(result.contains("gated feature"));
    }

    #[test]
    fn no_create_pending_skips_when_absent() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n",
        );

        let items = vec!["ignored item".to_string()];
        // With --no-create-backlog and backlog-only, should return Ok (no-op)
        run(&doc, None, None, None, false, &items, &[], true).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        // Nothing changed — no backlog component created, no blockquote
        assert!(!result.contains("agent:pending"));
        assert!(!result.contains("ignored item"));
    }

    #[test]
    fn pending_add_with_message_writes_both() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n",
        );

        let items = vec!["new backlog item".to_string()];
        run(
            &doc,
            Some("Notification msg"),
            None,
            None,
            false,
            &items,
            &[],
            false,
        )
        .unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        // Both backlog item and exchange blockquote should be present
        assert!(result.contains("new backlog item"));
        assert!(result.contains("> **[NOTIFY]**"));
        assert!(result.contains("> Notification msg"));
    }

    #[test]
    fn pending_only_typed_baseline_drives_crash_projection() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n",
        );

        let items = vec!["snapshot item".to_string()];
        run(&doc, None, None, None, false, &items, &[], false).unwrap();

        let baseline = agent_doc_snapshot_io::load_document_baseline(&doc)
            .unwrap()
            .expect("typed baseline");
        assert!(baseline.contains("snapshot item"));
        let crash_projection =
            std::fs::read_to_string(agent_doc_fs::snapshot_path_for(&doc).unwrap()).unwrap();
        assert_eq!(crash_projection, baseline);
    }

    #[test]
    fn auto_create_inserts_after_exchange() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "# Title\n\n<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n\n## Footer\n",
        );

        let items = vec!["test item".to_string()];
        run(&doc, None, None, None, false, &items, &[], false).unwrap();

        let result = std::fs::read_to_string(&doc).unwrap();
        let exchange_close = result.find("<!-- /agent:exchange -->").unwrap();
        let backlog_open = result.find("<!-- agent:backlog").unwrap();
        assert!(
            backlog_open > exchange_close,
            "backlog should be after exchange close"
        );
    }

    #[test]
    fn message_required_without_pending() {
        let dir = setup_project();
        let doc = write_doc(
            dir.path(),
            "test.md",
            "<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n",
        );

        let result = run(&doc, None, None, None, false, &[], &[], false);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("message is required")
        );
    }
}
