//! # Module: history
//!
//! ## Spec
//! - `list(file)`: walks `git log` for the file, extracts the `exchange` component from each
//!   commit's snapshot, and prints a table of `COMMIT`, `DATE`, and the first non-empty line of
//!   the exchange (truncated to 72 chars).  Reports "(no exchange component)" or "(file not
//!   available)" when the component is absent or the git show fails.
//! - `restore(file, commit)`: verifies the commit exists, extracts its `exchange` content, prepends
//!   it to the current document's exchange (separated by `---\n\n`), writes through document
//!   authority, and updates the snapshot.  If the current exchange is empty, no separator is
//!   inserted.
//! - Both functions require the file to be inside a git repository; `list` on a non-git file
//!   returns `Err`.
//!
//! ## Agentic Contracts
//! - `list` prints to stdout (the table) and stderr (warnings/empty messages); it never modifies
//!   any file.
//! - `restore` returns `Err` for a non-existent commit, a commit that does not contain the file,
//!   or a commit whose exchange component is empty.
//! - After `restore` returns `Ok`, old exchange content is prepended before the current content;
//!   the rest of the document is unchanged.
//! - Snapshot update on `restore` is best-effort: failure logs a warning but does not propagate.
//!
//! ## Evals
//! - extract_exchange_found: doc with exchange component → content extracted correctly
//! - extract_exchange_not_found: plain doc without component → returns None
//! - extract_exchange_empty: empty exchange tags → returns empty string
//! - extract_exchange_nested_components: exchange nested inside outer component → inner content extracted
//! - list_in_git_repo: file committed in temp git repo → list returns Ok without error
//! - restore_prepends_exchange: v1 committed, v2 current → restore inserts v1 before v2 with separator
//! - restore_into_empty_exchange: restore into empty exchange → content inserted without separator
//! - restore_nonexistent_commit_fails: bogus commit hash → returns Err

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

use agent_doc_element::element;

const EXCHANGE_COMPONENT: &str = "exchange";

/// Entry in the history list: one commit that touched the file.
struct HistoryEntry {
    commit: String,
    date: String,
    summary: String,
}

/// Extract the exchange component content from a document string.
/// Returns None if no exchange component is found.
fn extract_exchange(doc: &str) -> Option<String> {
    let components = element::parse(doc).ok()?;
    let exchange = components.iter().find(|c| c.name == EXCHANGE_COMPONENT)?;
    Some(exchange.content(doc).to_string())
}

/// Resolve the git root and relative path for a file.
/// Returns (git_root, relative_path).
fn resolve_git_paths(file: &Path) -> Result<(std::path::PathBuf, String)> {
    let canonical = file
        .canonicalize()
        .with_context(|| format!("file not found: {}", file.display()))?;
    let parent = canonical.parent().unwrap_or(Path::new("/"));

    let output = Command::new("git")
        .current_dir(parent)
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .context("failed to run git rev-parse")?;

    if !output.status.success() {
        bail!("file is not in a git repository: {}", file.display());
    }

    let git_root = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
    let rel_path = canonical
        .strip_prefix(&git_root)
        .with_context(|| {
            format!(
                "file {} is not under git root {}",
                canonical.display(),
                git_root.display()
            )
        })?
        .to_string_lossy()
        .to_string();

    Ok((git_root, rel_path))
}

/// List exchange component versions from git history.
///
/// Walks `git log` for the file, extracts the exchange content from each commit,
/// and prints a table with commit hash, date, and first line of exchange content.
pub fn list(file: &Path) -> Result<()> {
    let (git_root, rel_path) = resolve_git_paths(file)?;

    // Get commits that touched this file
    let output = Command::new("git")
        .current_dir(&git_root)
        .args(["log", "--format=%H %ai", "--", &rel_path])
        .output()
        .context("failed to run git log")?;

    if !output.status.success() {
        bail!("git log failed for {}", file.display());
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let lines: Vec<&str> = stdout.lines().collect();

    if lines.is_empty() {
        eprintln!("No git history found for {}", file.display());
        return Ok(());
    }

    // Collect history entries
    let mut entries: Vec<HistoryEntry> = Vec::new();

    for line in &lines {
        let parts: Vec<&str> = line.splitn(2, ' ').collect();
        if parts.len() < 2 {
            continue;
        }
        let commit = parts[0].to_string();
        let date = parts[1].to_string();

        // Get file content at this commit
        let show_output = Command::new("git")
            .current_dir(&git_root)
            .args(["show", &format!("{}:{}", commit, rel_path)])
            .output();

        let summary = match show_output {
            Ok(ref o) if o.status.success() => {
                let content = String::from_utf8_lossy(&o.stdout);
                match extract_exchange(&content) {
                    Some(exchange) => {
                        let first_line = exchange
                            .lines()
                            .find(|l| !l.trim().is_empty())
                            .unwrap_or("")
                            .to_string();
                        // Truncate for display
                        if first_line.len() > 72 {
                            format!("{}...", &first_line[..72])
                        } else {
                            first_line
                        }
                    }
                    None => "(no exchange component)".to_string(),
                }
            }
            _ => "(file not available)".to_string(),
        };

        entries.push(HistoryEntry {
            commit,
            date,
            summary,
        });
    }

    if entries.is_empty() {
        eprintln!("No commits found for {}", file.display());
        return Ok(());
    }

    // Print table header
    println!("{:<12} {:<26} EXCHANGE", "COMMIT", "DATE");
    println!("{}", "-".repeat(80));

    for entry in &entries {
        let short_commit = &entry.commit[..12.min(entry.commit.len())];
        println!("{:<12} {:<26} {}", short_commit, entry.date, entry.summary);
    }

    Ok(())
}

/// Restore exchange content from a specific commit.
///
/// Extracts the exchange content from the given commit, reads the current file,
/// and prepends the old content inside the exchange component (before existing
/// content), separated by `---\n\n`. Writes atomically and updates the snapshot.
pub fn restore(file: &Path, commit: &str) -> Result<()> {
    let (git_root, rel_path) = resolve_git_paths(file)?;

    // Verify the commit exists
    let output = Command::new("git")
        .current_dir(&git_root)
        .args(["cat-file", "-t", commit])
        .output()
        .context("failed to verify commit")?;

    if !output.status.success() {
        bail!("commit does not exist: {}", commit);
    }

    // Get file content at the specified commit
    let output = Command::new("git")
        .current_dir(&git_root)
        .args(["show", &format!("{}:{}", commit, rel_path)])
        .output()
        .context("failed to run git show")?;

    if !output.status.success() {
        bail!("file {} not found in commit {}", file.display(), commit);
    }

    let old_content = String::from_utf8_lossy(&output.stdout).to_string();

    // Extract exchange content from the old commit
    let old_exchange = extract_exchange(&old_content)
        .with_context(|| format!("no exchange component found in commit {}", commit))?;

    if old_exchange.trim().is_empty() {
        bail!("exchange component is empty in commit {}", commit);
    }

    // Resolve current file content from the active document authority.
    let current_content = agent_doc_document_realtime_io::try_resolve_current_document_content(
        file,
        "history_restore_command_document",
    )
    .with_context(|| format!("failed to resolve {}", file.display()))?;

    // Parse current document to find exchange component
    let components =
        element::parse(&current_content).with_context(|| "failed to parse current document")?;

    let exchange = components
        .iter()
        .find(|c| c.name == EXCHANGE_COMPONENT)
        .with_context(|| "no exchange component in current document")?;

    let current_exchange = exchange.content(&current_content);

    // Build new exchange content: old content + separator + existing content
    let new_exchange = if current_exchange.trim().is_empty() {
        old_exchange
    } else {
        format!("{}\n---\n\n{}", old_exchange.trim_end(), current_exchange)
    };

    // Replace exchange content in document
    let new_doc = exchange.replace_content(&current_content, &new_exchange);

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

    // Update snapshot (best-effort — may fail in environments without .agent-doc/)
    if let Err(e) = agent_doc_snapshot_io::checkpoint_document_baseline(
        file,
        &new_doc,
        agent_doc_ops_log_io::log_op,
    ) {
        eprintln!("[history] Warning: failed to update snapshot: {}", e);
    }

    let short_commit = &commit[..12.min(commit.len())];
    eprintln!(
        "[history] Restored exchange from {} into {}",
        short_commit,
        file.display()
    );

    Ok(())
}

/// Annotated git log for a session document.
///
/// Like `list` but also loads all `agent-doc/<name>/pre-compact-*` tags and
/// annotates matching commits with the tag name.
pub fn log(file: &Path) -> Result<()> {
    let (git_root, rel_path) = resolve_git_paths(file)?;

    let doc_name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("doc");

    // Load pre-compact tags: map commit → tag name
    let pattern = format!("agent-doc/{}/pre-compact-*", doc_name);
    let tag_out = Command::new("git")
        .current_dir(&git_root)
        .args([
            "tag",
            "-l",
            "--format=%(refname:short) %(objectname:short)",
            &pattern,
        ])
        .output()
        .unwrap_or_else(|_| std::process::Output {
            status: std::process::ExitStatus::default(),
            stdout: vec![],
            stderr: vec![],
        });

    // Build tag map: short-commit → tag-name
    let mut tag_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
    if tag_out.status.success() {
        for line in String::from_utf8_lossy(&tag_out.stdout).lines() {
            let parts: Vec<&str> = line.splitn(2, ' ').collect();
            if parts.len() == 2 {
                let tag_name = parts[0].to_string();
                let short_hash = parts[1].to_string();
                // Resolve to full commit hash so we can match against git log
                if let Ok(full_out) = Command::new("git")
                    .current_dir(&git_root)
                    .args(["rev-list", "-n1", &tag_name])
                    .output()
                {
                    let full = String::from_utf8_lossy(&full_out.stdout).trim().to_string();
                    if !full.is_empty() {
                        tag_map.insert(full[..full.len().min(12)].to_string(), tag_name.clone());
                        tag_map.insert(short_hash, tag_name);
                    }
                }
            }
        }
    }

    // Get commits that touched this file
    // Use NUL-delimited fields so spaces in dates/subjects don't break parsing.
    // Format: <hash>\0<date>\0<subject>\n
    let output = Command::new("git")
        .current_dir(&git_root)
        .args(["log", "--format=%H%x00%ai%x00%s", "--", &rel_path])
        .output()
        .context("failed to run git log")?;

    if !output.status.success() {
        bail!("git log failed for {}", file.display());
    }

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let entries: Vec<[&str; 3]> = stdout
        .lines()
        .filter_map(|line| {
            let mut parts = line.splitn(3, '\0');
            let hash = parts.next()?;
            let date = parts.next()?;
            let subject = parts.next()?;
            Some([hash, date, subject])
        })
        .collect();

    if entries.is_empty() {
        eprintln!("No git history found for {}", file.display());
        return Ok(());
    }

    println!("{:<12} {:<32} {:<30} TAG", "COMMIT", "DATE", "SUBJECT");
    println!("{}", "-".repeat(90));

    for [commit, date, subject] in &entries {
        let short = &commit[..commit.len().min(12)];
        let tag = tag_map.get(short).map(|s| s.as_str()).unwrap_or("");
        let subj_display = if subject.len() > 30 {
            format!("{}...", &subject[..28])
        } else {
            subject.to_string()
        };
        println!("{:<12} {:<32} {:<30} {}", short, date, subj_display, tag);
    }

    Ok(())
}

/// Show document content at a specific point in history.
///
/// Options:
/// - `back`: `HEAD~N` (e.g. `back=1` → `HEAD~1`)
/// - `at`: Nth commit from newest in git log (0 = HEAD, 1 = next oldest, …)
/// - `tag`: resolve the named tag to its commit
///
/// Prints the full document content to stdout.
pub fn show(file: &Path, back: Option<usize>, at: Option<usize>, tag: Option<&str>) -> Result<()> {
    let (git_root, rel_path) = resolve_git_paths(file)?;

    let commit_ref = if let Some(t) = tag {
        // Resolve tag to commit
        let out = Command::new("git")
            .current_dir(&git_root)
            .args(["rev-list", "-n1", t])
            .output()
            .with_context(|| format!("failed to resolve tag {}", t))?;
        if !out.status.success() {
            bail!("tag not found: {}", t);
        }
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    } else if let Some(n) = back {
        format!("HEAD~{}", n)
    } else if let Some(n) = at {
        // Get commit at position n from newest
        let out = Command::new("git")
            .current_dir(&git_root)
            .args(["log", "--format=%H", "--", &rel_path])
            .output()
            .context("failed to run git log")?;
        if !out.status.success() {
            bail!("git log failed");
        }
        let stdout = String::from_utf8_lossy(&out.stdout).to_string();
        let commits_owned: Vec<String> = stdout.lines().map(|l| l.to_string()).collect();
        if n >= commits_owned.len() {
            bail!(
                "--at {} exceeds history length ({})",
                n,
                commits_owned.len()
            );
        }
        commits_owned[n].clone()
    } else {
        "HEAD".to_string()
    };

    let out = Command::new("git")
        .current_dir(&git_root)
        .args(["show", &format!("{}:{}", commit_ref, rel_path)])
        .output()
        .context("failed to run git show")?;

    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        bail!("git show failed: {}", stderr.trim());
    }

    print!("{}", String::from_utf8_lossy(&out.stdout));
    Ok(())
}

/// Show a unified diff of the document between two git refs.
///
/// - `from`: starting ref (e.g. commit hash, tag, `HEAD~2`)
/// - `to`: ending ref (default: `HEAD`)
pub fn git_diff(file: &Path, from: &str, to: &str) -> Result<()> {
    let (git_root, rel_path) = resolve_git_paths(file)?;

    let output = Command::new("git")
        .current_dir(&git_root)
        .args(["diff", &format!("{}..{}", from, to), "--", &rel_path])
        .output()
        .context("failed to run git diff")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("git diff failed: {}", stderr.trim());
    }

    print!("{}", String::from_utf8_lossy(&output.stdout));
    Ok(())
}

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

    #[test]
    fn extract_exchange_found() {
        let doc = "\
# Title
<!-- agent:exchange -->
Hello world
Second line
<!-- /agent:exchange -->
Footer
";
        let content = extract_exchange(doc).unwrap();
        assert_eq!(content, "Hello world\nSecond line\n");
    }

    #[test]
    fn extract_exchange_not_found() {
        let doc = "# Just a plain doc\n";
        assert!(extract_exchange(doc).is_none());
    }

    #[test]
    fn extract_exchange_empty() {
        let doc = "<!-- agent:exchange --><!-- /agent:exchange -->\n";
        let content = extract_exchange(doc).unwrap();
        assert_eq!(content, "");
    }

    #[test]
    fn extract_exchange_nested_components() {
        let doc = "\
<!-- agent:outer -->
<!-- agent:exchange -->
inner content
<!-- /agent:exchange -->
<!-- /agent:outer -->
";
        let content = extract_exchange(doc).unwrap();
        assert_eq!(content, "inner content\n");
    }

    /// Integration test: list requires a git repo, so we set one up in a tempdir.
    #[test]
    fn list_in_git_repo() {
        let dir = tempfile::TempDir::new().unwrap();
        let doc = dir.path().join("test.md");
        let content = "\
---
title: Test
---
<!-- agent:exchange -->
First exchange
<!-- /agent:exchange -->
";
        fs::write(&doc, content).unwrap();

        // Init git repo and commit
        Command::new("git")
            .current_dir(dir.path())
            .args(["init"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["add", "test.md"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["commit", "-m", "initial", "--no-verify"])
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();

        // list should succeed without error
        let result = list(&doc);
        assert!(result.is_ok(), "list failed: {:?}", result.err());
    }

    /// Integration test: restore prepends old exchange content.
    #[test]
    fn restore_prepends_exchange() {
        let dir = tempfile::TempDir::new().unwrap();
        let doc = dir.path().join("test.md");

        // First version
        let v1 = "\
<!-- agent:exchange -->
Old exchange content
<!-- /agent:exchange -->
";
        fs::write(&doc, v1).unwrap();

        // Init git repo and commit v1
        Command::new("git")
            .current_dir(dir.path())
            .args(["init"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["add", "test.md"])
            .output()
            .unwrap();
        let commit_out = Command::new("git")
            .current_dir(dir.path())
            .args(["commit", "-m", "v1", "--no-verify"])
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();
        assert!(commit_out.status.success(), "commit v1 failed");

        // Get the commit hash
        let log_out = Command::new("git")
            .current_dir(dir.path())
            .args(["log", "--format=%H", "-1"])
            .output()
            .unwrap();
        let v1_commit = String::from_utf8_lossy(&log_out.stdout).trim().to_string();

        // Write v2 (new content)
        let v2 = "\
<!-- agent:exchange -->
New exchange content
<!-- /agent:exchange -->
";
        fs::write(&doc, v2).unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["add", "test.md"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["commit", "-m", "v2", "--no-verify"])
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();

        // Restore v1 exchange into current
        let result = restore(&doc, &v1_commit);
        assert!(result.is_ok(), "restore failed: {:?}", result.err());

        // Verify the result
        let restored = fs::read_to_string(&doc).unwrap();
        assert!(
            restored.contains("Old exchange content"),
            "should contain old content"
        );
        assert!(
            restored.contains("New exchange content"),
            "should still contain new content"
        );
        assert!(restored.contains("---"), "should contain separator");

        // Verify order: old content should come before new content
        let old_pos = restored.find("Old exchange content").unwrap();
        let new_pos = restored.find("New exchange content").unwrap();
        assert!(
            old_pos < new_pos,
            "old content should be prepended before new content"
        );
    }

    /// Edge case: restore into an empty exchange component.
    #[test]
    fn restore_into_empty_exchange() {
        let dir = tempfile::TempDir::new().unwrap();
        let doc = dir.path().join("test.md");

        // v1 with content
        let v1 = "\
<!-- agent:exchange -->
Historical content
<!-- /agent:exchange -->
";
        fs::write(&doc, v1).unwrap();

        Command::new("git")
            .current_dir(dir.path())
            .args(["init"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["add", "test.md"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["commit", "-m", "v1", "--no-verify"])
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();

        let log_out = Command::new("git")
            .current_dir(dir.path())
            .args(["log", "--format=%H", "-1"])
            .output()
            .unwrap();
        let v1_commit = String::from_utf8_lossy(&log_out.stdout).trim().to_string();

        // v2 with empty exchange
        let v2 = "\
<!-- agent:exchange -->
<!-- /agent:exchange -->
";
        fs::write(&doc, v2).unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["add", "test.md"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["commit", "-m", "v2", "--no-verify"])
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();

        let result = restore(&doc, &v1_commit);
        assert!(result.is_ok(), "restore failed: {:?}", result.err());

        let restored = fs::read_to_string(&doc).unwrap();
        assert!(
            restored.contains("Historical content"),
            "should contain restored content"
        );
        // Should NOT have separator since exchange was empty
        assert!(
            !restored.contains("---"),
            "should not have separator when restoring into empty exchange"
        );
    }

    #[test]
    fn restore_nonexistent_commit_fails() {
        let dir = tempfile::TempDir::new().unwrap();
        let doc = dir.path().join("test.md");
        fs::write(&doc, "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n").unwrap();

        Command::new("git")
            .current_dir(dir.path())
            .args(["init"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["add", "test.md"])
            .output()
            .unwrap();
        Command::new("git")
            .current_dir(dir.path())
            .args(["commit", "-m", "init", "--no-verify"])
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@test.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@test.com")
            .output()
            .unwrap();

        let result = restore(&doc, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
        assert!(result.is_err(), "should fail for nonexistent commit");
    }

    #[test]
    fn list_file_not_in_git_fails() {
        let dir = tempfile::TempDir::new().unwrap();
        let doc = dir.path().join("test.md");
        fs::write(&doc, "content").unwrap();
        // No git init — should fail
        let result = list(&doc);
        assert!(result.is_err(), "should fail when file is not in git repo");
    }
}
