//! Phase 10.9 — local git repo around the agent workspace.
//!
//! Provides forensics (`git log`), rollback (`git revert` on disk), and
//! blame without any remote. The caller commits at natural boundaries:
//! dreaming sweeps, explicit `forge_memory_checkpoint` tool invocations.
use git2::{DiffFormat, IndexAddOption, ObjectType, Oid, Repository, Signature, Sort};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const GITIGNORE_BODY: &str = "# Auto-generated by agent workspace_git. Customize at will.\n\
transcripts/\n\
media/\n\
*.tmp\n\
*.swp\n\
.DS_Store\n";
const GITATTRIBUTES_BODY: &str = "*.md text eol=lf\n\
*.yaml text eol=lf\n\
*.json text eol=lf\n";
/// Files bigger than this are excluded from commits (logged, not fatal).
pub const MAX_COMMIT_FILE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
pub struct CommitSummary {
    /// Full 40-char hex oid.
    pub oid: String,
    /// 7-char short form for display.
    pub short_oid: String,
    pub subject: String,
    pub body: String,
    pub author: String,
    pub timestamp_unix: i64,
}
pub struct MemoryGitRepo {
    root: PathBuf,
    author_name: String,
    author_email: String,
    /// libgit2 `Repository` is not `Sync`. Serialize access with a mutex so
    /// this struct is safe behind `Arc<..>` across threads.
    inner: Mutex<Repository>,
    /// Phase 77.7 — secret guard for scanning staged content before commit.
    guard: Option<nexo_memory::SecretGuard>,
    /// Phase 36.2 (MS-1.b) — optional mutation observer. Fires once
    /// per successful commit (skipped on a clean tree). Best-effort:
    /// the hook is dispatched via `tokio::spawn` from inside the
    /// sync `commit_all`, so a hook failure cannot poison the
    /// commit. When no tokio runtime is available the spawn is
    /// silently skipped — `commit_all` still works in test code
    /// that runs outside an async context.
    mutation_hook: Option<std::sync::Arc<dyn nexo_driver_types::MemoryMutationHook>>,
    /// Agent identifier passed to the mutation hook. Empty string
    /// (default) suppresses the event so a freshly-constructed repo
    /// without operator identity does not emit half-formed events.
    agent_id: String,
    /// Tenant string passed to the mutation hook. Defaults to
    /// `"default"` for single-tenant deployments.
    tenant: String,
}
impl MemoryGitRepo {
    /// Open an existing `.git` at `root`, or init a fresh repo plus
    /// `.gitignore` / `.gitattributes` + an initial commit.
    pub fn open_or_init(
        root: &Path,
        author_name: impl Into<String>,
        author_email: impl Into<String>,
    ) -> anyhow::Result<Self> {
        fs::create_dir_all(root).ok();
        let author_name = author_name.into();
        let author_email = author_email.into();
        let repo = match Repository::open(root) {
            Ok(r) => r,
            Err(_) => {
                let r = Repository::init(root)?;
                write_bootstrap_files(root)?;
                bootstrap_commit(&r, &author_name, &author_email)?;
                r
            }
        };
        Ok(Self {
            root: root.to_path_buf(),
            author_name,
            author_email,
            inner: Mutex::new(repo),
            guard: None,
            mutation_hook: None,
            agent_id: String::new(),
            tenant: "default".into(),
        })
    }

    /// Phase 77.7 — attach a secret guard for scanning staged content
    /// before commit. On Block, the commit is aborted.
    pub fn with_guard(mut self, guard: nexo_memory::SecretGuard) -> Self {
        self.guard = Some(guard);
        self
    }

    /// Phase 36.2 (MS-1.b) — attach a mutation observer + the
    /// `(agent_id, tenant)` pair the event carries. The hook is
    /// fired post-successful-commit via `tokio::spawn` so it never
    /// blocks the libgit2 thread.
    pub fn with_mutation_hook(
        mut self,
        hook: std::sync::Arc<dyn nexo_driver_types::MemoryMutationHook>,
        agent_id: impl Into<String>,
        tenant: impl Into<String>,
    ) -> Self {
        self.mutation_hook = Some(hook);
        self.agent_id = agent_id.into();
        self.tenant = tenant.into();
        self
    }
    pub fn root(&self) -> &Path {
        &self.root
    }
    /// Stage every non-ignored change (skipping blobs > `MAX_COMMIT_FILE_BYTES`)
    /// and commit. Returns `Ok(None)` if the worktree was clean.
    pub fn commit_all(&self, subject: &str, body: &str) -> anyhow::Result<Option<Oid>> {
        let repo = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        let mut index = repo.index()?;
        // First pass — add all tracked + untracked (respects .gitignore).
        index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
        index.write()?;
        // Skip oversize files. Iterate current entries, drop any whose blob
        // exceeds the limit. Log which ones.
        let oversized: Vec<String> = index
            .iter()
            .filter_map(|entry| {
                if entry.file_size as u64 > MAX_COMMIT_FILE_BYTES {
                    Some(
                        std::str::from_utf8(&entry.path)
                            .unwrap_or("<non-utf8>")
                            .to_string(),
                    )
                } else {
                    None
                }
            })
            .collect();
        for path in &oversized {
            tracing::warn!(
                path = %path,
                limit = MAX_COMMIT_FILE_BYTES,
                "workspace_git: skipping oversized file"
            );
            index.remove_path(Path::new(path))?;
        }
        if !oversized.is_empty() {
            index.write()?;
        }
        // Phase 77.7 — scan staged files for secrets before committing.
        if let Some(ref guard) = self.guard {
            if guard.is_enabled() {
                let mut blocked: Vec<String> = Vec::new();
                for entry in index.iter() {
                    let rel = match std::str::from_utf8(&entry.path) {
                        Ok(p) => p,
                        Err(_) => continue,
                    };
                    let abs_path = self.root.join(rel);
                    let content = match std::fs::read_to_string(&abs_path) {
                        Ok(c) => c,
                        Err(_) => continue, // binary or missing
                    };
                    if guard.has_secrets(&content) {
                        match guard.on_secret() {
                            nexo_memory::OnSecret::Warn => {
                                let matches = guard.scan_for_display(&content);
                                tracing::warn!(
                                    target = "memory.secret.warned",
                                    rule_ids = ?matches.iter().map(|m| m.rule_id).collect::<Vec<_>>(),
                                    path = %rel,
                                    workspace = %self.root.display(),
                                    "workspace_git: secrets found in staged file (warn policy, proceeding)"
                                );
                            }
                            _ => {
                                // Block or Redact — both abort for git.
                                // Redact can't safely modify git-tracked files
                                // without destructive working-tree changes.
                                let matches = guard.scan_for_display(&content);
                                let labels: Vec<&str> = matches.iter().map(|m| m.label).collect();
                                tracing::warn!(
                                    target = "memory.secret.blocked",
                                    rule_ids = ?matches.iter().map(|m| m.rule_id).collect::<Vec<_>>(),
                                    path = %rel,
                                    workspace = %self.root.display(),
                                    "workspace_git: commit blocked by secret scanner"
                                );
                                blocked.push(format!("{} ({})", rel, labels.join(", ")));
                            }
                        }
                    }
                }
                if !blocked.is_empty() {
                    anyhow::bail!(
                        "secret scan blocked git commit — {} file(s): {}",
                        blocked.len(),
                        blocked.join("; ")
                    );
                }
            }
        }
        let tree_id = index.write_tree()?;
        let tree = repo.find_tree(tree_id)?;
        // Detect clean tree (matches HEAD).
        if let Ok(head_ref) = repo.head() {
            if let Some(head_oid) = head_ref.target() {
                let head_commit = repo.find_commit(head_oid)?;
                if head_commit.tree_id() == tree_id {
                    return Ok(None);
                }
            }
        }
        let sig = Signature::now(&self.author_name, &self.author_email)?;
        let message = format_message(subject, body);
        let parents: Vec<git2::Commit> = match repo.head() {
            Ok(head_ref) => head_ref
                .target()
                .and_then(|oid| repo.find_commit(oid).ok())
                .into_iter()
                .collect(),
            Err(_) => Vec::new(),
        };
        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
        let oid = repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs)?;

        // Phase 36.2 (MS-1.b) — best-effort fire of the mutation
        // observer post-success. Spawn from a tokio handle if one
        // is available; otherwise silently skip so this method
        // stays usable from non-async test contexts.
        if let (Some(hook), false) = (&self.mutation_hook, self.agent_id.is_empty()) {
            if let Ok(handle) = tokio::runtime::Handle::try_current() {
                let hook = hook.clone();
                let agent_id = self.agent_id.clone();
                let tenant = self.tenant.clone();
                let oid_str = oid.to_string();
                handle.spawn(async move {
                    hook.on_mutation(
                        &agent_id,
                        &tenant,
                        nexo_driver_types::MemoryMutationScope::Git,
                        nexo_driver_types::MemoryMutationOp::Update,
                        &oid_str,
                    )
                    .await;
                });
            }
        }

        Ok(Some(oid))
    }
    /// Last `limit` commits reachable from HEAD, newest first.
    pub fn log(&self, limit: usize) -> anyhow::Result<Vec<CommitSummary>> {
        let repo = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        let mut walk = repo.revwalk()?;
        // Topological order from HEAD — children (newer) come before parents.
        walk.set_sorting(Sort::NONE)?;
        if walk.push_head().is_err() {
            return Ok(Vec::new());
        }
        let mut out = Vec::with_capacity(limit.min(32));
        for (i, oid_result) in walk.enumerate() {
            if i >= limit {
                break;
            }
            let oid = oid_result?;
            let c = repo.find_commit(oid)?;
            let full_msg = c.message().unwrap_or("").to_string();
            let (subject, body) = split_subject_body(&full_msg);
            let full_oid = format!("{oid}");
            let short_oid: String = full_oid.chars().take(7).collect();
            out.push(CommitSummary {
                oid: full_oid,
                short_oid,
                subject,
                body,
                author: c.author().name().map(|s| s.to_string()).unwrap_or_default(),
                timestamp_unix: c.time().seconds(),
            });
        }
        Ok(out)
    }
    /// Unified patch text from `from_oid`..HEAD. When `None`, compares
    /// HEAD~1..HEAD. Empty string when the repo has only one commit.
    pub fn diff_since(&self, from_oid: Option<Oid>) -> anyhow::Result<String> {
        let repo = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        let head_tree = match repo.head() {
            Ok(head_ref) => head_ref
                .target()
                .and_then(|oid| repo.find_commit(oid).ok())
                .map(|c| c.tree().ok())
                .unwrap_or(None),
            Err(_) => return Ok(String::new()),
        };
        let Some(head_tree) = head_tree else {
            return Ok(String::new());
        };
        let from_tree = match from_oid {
            Some(oid) => repo.find_commit(oid)?.tree()?,
            None => {
                // HEAD~1 — if HEAD exists but has no target (the
                // symbolic-ref-without-oid corner case), there's
                // nothing to diff against.
                let Some(head) = repo.head()?.target() else {
                    return Ok(String::new());
                };
                let head_commit = repo.find_commit(head)?;
                match head_commit.parent(0) {
                    Ok(p) => p.tree()?,
                    Err(_) => return Ok(String::new()),
                }
            }
        };
        let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&head_tree), None)?;
        let mut out = String::new();
        diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
            match line.origin() {
                '+' | '-' | ' ' => out.push(line.origin()),
                _ => {}
            }
            if let Ok(s) = std::str::from_utf8(line.content()) {
                out.push_str(s);
            }
            true
        })?;
        Ok(out)
    }
}
fn write_bootstrap_files(root: &Path) -> anyhow::Result<()> {
    let gi = root.join(".gitignore");
    if !gi.exists() {
        fs::write(&gi, GITIGNORE_BODY)?;
    }
    let ga = root.join(".gitattributes");
    if !ga.exists() {
        fs::write(&ga, GITATTRIBUTES_BODY)?;
    }
    Ok(())
}
fn bootstrap_commit(
    repo: &Repository,
    author_name: &str,
    author_email: &str,
) -> anyhow::Result<Oid> {
    let mut index = repo.index()?;
    index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
    index.write()?;
    let tree_id = index.write_tree()?;
    let tree = repo.find_tree(tree_id)?;
    let sig = Signature::now(author_name, author_email)?;
    let oid = repo.commit(
        Some("HEAD"),
        &sig,
        &sig,
        "workspace init\n\nAuto-generated bootstrap commit.",
        &tree,
        &[],
    )?;
    // Ensure HEAD points at a main branch for predictability.
    if repo
        .head()
        .map(|r| r.shorthand().is_none())
        .unwrap_or(false)
    {
        // No-op; some libgit2 versions accept the HEAD pointed at refs/heads/master by default.
    }
    // Work around detached-HEAD on some libgit2 versions when the initial commit
    // is made before any branch exists: attach the commit to `refs/heads/main`.
    let _ = repo.reference("refs/heads/main", oid, true, "bootstrap");
    let _ = repo.set_head("refs/heads/main");
    let _ = ObjectType::Commit; // quiet unused-import warning when refactoring
    Ok(oid)
}
fn format_message(subject: &str, body: &str) -> String {
    let subject = subject.trim();
    let body = body.trim();
    if body.is_empty() {
        format!("{subject}\n")
    } else {
        format!("{subject}\n\n{body}\n")
    }
}
fn split_subject_body(message: &str) -> (String, String) {
    match message.split_once("\n\n") {
        Some((s, b)) => (s.trim().to_string(), b.trim().to_string()),
        None => (message.trim().to_string(), String::new()),
    }
}

/// Phase 80.1.g — adapter wrapping `Arc<MemoryGitRepo>` as a
/// `MemoryCheckpointer` so nexo-dream's `AutoDreamRunner` can record
/// fork-pass output as a git commit on a successful Completed run.
///
/// Newtype is required by Rust's orphan rule (`impl ForeignTrait for
/// Arc<Local>` is not allowed); this wrapper is local-on-local.
///
/// Wraps the blocking `commit_all` call in `tokio::task::spawn_blocking`
/// because `git2::Repository` operations are sync-only. The
/// `MemoryGitRepo` internal `Mutex<Repository>` already serialises
/// concurrent callers from other code paths (e.g. session-close
/// commit, scoring-sweep commit).
pub struct MemoryGitCheckpointer {
    repo: std::sync::Arc<MemoryGitRepo>,
}

impl MemoryGitCheckpointer {
    pub fn new(repo: std::sync::Arc<MemoryGitRepo>) -> Self {
        Self { repo }
    }
}

#[async_trait::async_trait]
impl nexo_driver_types::MemoryCheckpointer for MemoryGitCheckpointer {
    async fn checkpoint(&self, subject: String, body: String) -> Result<(), String> {
        let repo = std::sync::Arc::clone(&self.repo);
        tokio::task::spawn_blocking(move || {
            repo.commit_all(&subject, &body)
                .map(|_oid| ())
                .map_err(|e| e.to_string())
        })
        .await
        .map_err(|e| format!("spawn_blocking join: {e}"))?
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    fn repo_in(td: &TempDir) -> MemoryGitRepo {
        MemoryGitRepo::open_or_init(td.path(), "kate", "kate@test").unwrap()
    }
    #[test]
    fn init_creates_git_dir_and_bootstrap_commit() {
        let td = TempDir::new().unwrap();
        let repo = repo_in(&td);
        assert!(td.path().join(".git").exists());
        assert!(td.path().join(".gitignore").exists());
        assert!(td.path().join(".gitattributes").exists());
        let log = repo.log(10).unwrap();
        assert_eq!(log.len(), 1);
        assert_eq!(log[0].subject, "workspace init");
    }
    #[test]
    fn commit_all_creates_new_commit() {
        let td = TempDir::new().unwrap();
        let repo = repo_in(&td);
        std::fs::write(td.path().join("MEMORY.md"), "# hello\n").unwrap();
        let oid = repo.commit_all("memory: note", "added MEMORY.md").unwrap();
        assert!(oid.is_some());
        let log = repo.log(10).unwrap();
        assert_eq!(log.len(), 2);
        assert_eq!(log[0].subject, "memory: note");
    }
    #[tokio::test]
    async fn commit_all_fires_mutation_hook_on_success() {
        use async_trait::async_trait;
        use std::sync::{Arc, Mutex};
        #[derive(Default)]
        struct Recorder {
            events: Mutex<Vec<(String, String, String)>>,
        }
        #[async_trait]
        impl nexo_driver_types::MemoryMutationHook for Recorder {
            async fn on_mutation(
                &self,
                agent_id: &str,
                tenant: &str,
                _scope: nexo_driver_types::MemoryMutationScope,
                _op: nexo_driver_types::MemoryMutationOp,
                key: &str,
            ) {
                self.events.lock().unwrap().push((
                    agent_id.to_string(),
                    tenant.to_string(),
                    key.to_string(),
                ));
            }
        }
        let td = TempDir::new().unwrap();
        let rec = Arc::new(Recorder::default());
        let hook: Arc<dyn nexo_driver_types::MemoryMutationHook> = rec.clone();
        let repo = MemoryGitRepo::open_or_init(td.path(), "kate", "kate@test")
            .unwrap()
            .with_mutation_hook(hook, "ana", "acme");
        std::fs::write(td.path().join("MEMORY.md"), "# hello\n").unwrap();
        let oid = repo.commit_all("memory: note", "").unwrap();
        assert!(oid.is_some());
        // The hook is fire-and-forget via `tokio::spawn`; yield long
        // enough for the spawned task to run.
        for _ in 0..20 {
            if !rec.events.lock().unwrap().is_empty() {
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
        }
        let evs = rec.events.lock().unwrap();
        assert_eq!(evs.len(), 1, "exactly one Git mutation event must fire");
        assert_eq!(evs[0].0, "ana");
        assert_eq!(evs[0].1, "acme");
        assert_eq!(evs[0].2, oid.unwrap().to_string());
    }

    #[tokio::test]
    async fn commit_all_does_not_fire_hook_on_clean_tree() {
        use async_trait::async_trait;
        use std::sync::{Arc, Mutex};
        struct Counter(Mutex<u32>);
        #[async_trait]
        impl nexo_driver_types::MemoryMutationHook for Counter {
            async fn on_mutation(
                &self,
                _: &str,
                _: &str,
                _: nexo_driver_types::MemoryMutationScope,
                _: nexo_driver_types::MemoryMutationOp,
                _: &str,
            ) {
                *self.0.lock().unwrap() += 1;
            }
        }
        let td = TempDir::new().unwrap();
        let c = Arc::new(Counter(Mutex::new(0)));
        let hook: Arc<dyn nexo_driver_types::MemoryMutationHook> = c.clone();
        let repo = MemoryGitRepo::open_or_init(td.path(), "kate", "kate@test")
            .unwrap()
            .with_mutation_hook(hook, "ana", "default");
        // No staged changes — commit_all returns None and must NOT
        // emit an event.
        let oid = repo.commit_all("noop", "").unwrap();
        assert!(oid.is_none());
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
        assert_eq!(*c.0.lock().unwrap(), 0);
    }

    #[test]
    fn commit_all_on_clean_tree_returns_none() {
        let td = TempDir::new().unwrap();
        let repo = repo_in(&td);
        let oid = repo.commit_all("noop", "").unwrap();
        assert!(oid.is_none());
    }
    #[test]
    fn commit_all_skips_oversized_files() {
        let td = TempDir::new().unwrap();
        let repo = repo_in(&td);
        std::fs::write(td.path().join("MEMORY.md"), "# normal\n").unwrap();
        let big = vec![b'x'; (MAX_COMMIT_FILE_BYTES + 1) as usize];
        std::fs::write(td.path().join("big.bin"), big).unwrap();
        let oid = repo.commit_all("memory: note + big", "").unwrap();
        // Commit should succeed (MEMORY.md changed), big.bin excluded.
        assert!(oid.is_some());
        let log = repo.log(10).unwrap();
        assert_eq!(log.len(), 2);
    }
    #[test]
    fn log_returns_newest_first() {
        let td = TempDir::new().unwrap();
        let repo = repo_in(&td);
        std::fs::write(td.path().join("a.md"), "a\n").unwrap();
        repo.commit_all("a", "").unwrap();
        std::fs::write(td.path().join("b.md"), "b\n").unwrap();
        repo.commit_all("b", "").unwrap();
        let log = repo.log(10).unwrap();
        assert_eq!(log.len(), 3);
        assert_eq!(log[0].subject, "b");
        assert_eq!(log[1].subject, "a");
        assert_eq!(log[2].subject, "workspace init");
    }
    #[test]
    fn diff_since_includes_changes() {
        let td = TempDir::new().unwrap();
        let repo = repo_in(&td);
        std::fs::write(td.path().join("MEMORY.md"), "first\n").unwrap();
        let first = repo.commit_all("first", "").unwrap().unwrap();
        std::fs::write(td.path().join("MEMORY.md"), "second\n").unwrap();
        repo.commit_all("second", "").unwrap();
        let diff = repo.diff_since(Some(first)).unwrap();
        assert!(
            diff.contains("+second"),
            "diff should show additions: {diff}"
        );
        assert!(
            diff.contains("-first"),
            "diff should show deletions: {diff}"
        );
    }

    // ── Phase 80.1.g — MemoryGitCheckpointer adapter ──

    #[tokio::test]
    async fn checkpointer_async_calls_commit_all() {
        use nexo_driver_types::MemoryCheckpointer;

        let td = TempDir::new().unwrap();
        let repo = std::sync::Arc::new(repo_in(&td));
        let ckpt = MemoryGitCheckpointer::new(repo.clone());

        std::fs::write(td.path().join("MEMORY.md"), "hello\n").unwrap();
        ckpt.checkpoint("auto_dream: 1 file(s) consolidated".into(), "body".into())
            .await
            .unwrap();

        let log = repo.log(10).unwrap();
        // initial commit + ours = 2 entries.
        assert_eq!(log.len(), 2);
        assert_eq!(log[0].subject, "auto_dream: 1 file(s) consolidated");
        assert_eq!(log[0].body, "body");
    }

    #[tokio::test]
    async fn checkpointer_returns_ok_on_clean_worktree() {
        // commit_all returns Ok(None) when nothing changed; the
        // adapter collapses that to Ok(()) — no error, no commit.
        use nexo_driver_types::MemoryCheckpointer;

        let td = TempDir::new().unwrap();
        let repo = std::sync::Arc::new(repo_in(&td));
        let ckpt = MemoryGitCheckpointer::new(repo.clone());

        let log_before = repo.log(10).unwrap().len();
        ckpt.checkpoint("noop".into(), "".into()).await.unwrap();
        let log_after = repo.log(10).unwrap().len();
        assert_eq!(
            log_before, log_after,
            "clean worktree should not add a commit"
        );
    }
}
