//! Session-start environment snapshot (C1 in CC's taxonomy).
//!
//! Captures git branch / HEAD / working-tree status **once** at session
//! start (or on `ChangeDir`), memoized by ownership: the struct lives on
//! `AgentLoop`, is constructed in `new()`, and renders into
//! `build_system_prompt` as a dedicated section.
//!
//! ## Why snapshot, not live
//!
//! Git status changes every turn (every tool call shifts working tree
//! state). If we re-read each turn, the system-prompt prefix changes
//! every turn → prompt cache breaks → every turn re-bills full prefix.
//! CC observed this tradeoff and chose snapshot-at-session-start with an
//! explicit disclaimer in prompt text, so the model doesn't mistake
//! stale info for live state. We follow that pattern.
//!
//! ## Graceful degradation
//!
//! Non-git directories, no git binary on PATH, detached HEAD, permission
//! errors — all surface as `git: None` and the prompt section is simply
//! omitted. No panics, no half-rendered sections.

use std::path::Path;
use std::process::Command;

/// Git repository state at the moment of capture.
#[derive(Debug, Clone, Default)]
pub struct GitSnapshot {
    /// Current branch name. `None` in detached-HEAD state.
    pub branch: Option<String>,
    /// One-line HEAD description: `"<short-sha> <subject>"`.
    pub head_oneline: Option<String>,
    /// `git status --short` output, truncated to [`STATUS_MAX_LINES`] lines.
    pub status_short: String,
    /// `true` when the working tree has uncommitted changes / untracked files.
    pub is_dirty: bool,
}

/// Hard cap on `git status --short` lines rendered into prompt.
/// Prevents a repo with 500 untracked files from blowing up system prompt.
const STATUS_MAX_LINES: usize = 20;

/// Session-start environment snapshot.
///
/// Only git is captured today. Extend by adding fields (e.g. `rust_toolchain`,
/// `node_version`) and rendering them in [`as_prompt_section`].
#[derive(Debug, Clone, Default)]
pub struct EnvSnapshot {
    pub git: Option<GitSnapshot>,
}

impl EnvSnapshot {
    /// Capture the current git state at `wd`. Blocking I/O — acceptable
    /// at session start / `ChangeDir`, which run at most a few times per
    /// process. Returns an empty snapshot (`git: None`) on any failure.
    pub fn capture(wd: &Path) -> Self {
        // First gate: is `wd` inside a git work tree at all? This avoids
        // stderr spam in plain directories where every `git` subcommand
        // would complain identically.
        if !is_git_repo(wd) {
            return Self::default();
        }

        let branch = run_git(wd, &["branch", "--show-current"]).filter(|s| !s.is_empty());

        let head_oneline = run_git(wd, &["log", "-1", "--format=%h %s"]).filter(|s| !s.is_empty());

        let status_raw = run_git(wd, &["status", "--short"]).unwrap_or_default();
        let is_dirty = !status_raw.trim().is_empty();

        // Truncate aggressively — long status (e.g. first clone with many
        // untracked files) shouldn't dominate the system prompt.
        let status_short = truncate_status(&status_raw, STATUS_MAX_LINES);

        Self {
            git: Some(GitSnapshot {
                branch,
                head_oneline,
                status_short,
                is_dirty,
            }),
        }
    }

    /// Render as a `=== GIT STATUS ===` block for splicing into the
    /// system prompt. Returns `String::new()` when no git state exists
    /// (so the caller can unconditionally push the result without
    /// checking — empty string is a no-op).
    ///
    /// Format (matches CC's snapshot disclaimer pattern):
    ///
    /// ```text
    /// === GIT STATUS (snapshot at session start, not live) ===
    /// Branch: main
    /// HEAD:   06f4537 feat(tuix): /context 命令
    /// Status: 3 files modified
    ///  M src/foo.rs
    ///  M src/bar.rs
    /// ?? new.rs
    ///
    /// This is a snapshot from session start. Use `bash` + `git status`
    /// to check live state.
    /// ```
    pub fn as_prompt_section(&self) -> String {
        let Some(git) = self.git.as_ref() else {
            return String::new();
        };

        let mut out = String::from("\n=== GIT STATUS (snapshot at session start, not live) ===\n");

        if let Some(branch) = git.branch.as_deref() {
            out.push_str(&format!("Branch: {}\n", branch));
        } else {
            out.push_str("Branch: (detached HEAD)\n");
        }

        if let Some(head) = git.head_oneline.as_deref() {
            out.push_str(&format!("HEAD:   {}\n", head));
        }

        if git.is_dirty {
            let line_count = git.status_short.lines().count();
            out.push_str(&format!("Status: {} change(s)\n", line_count));
            out.push_str(&git.status_short);
            if !git.status_short.ends_with('\n') {
                out.push('\n');
            }
        } else {
            out.push_str("Status: clean\n");
        }

        out.push_str(
            "\nThis is a snapshot from session start. \
             Use `bash` + `git status` to check live state.\n",
        );

        out
    }
}

/// True when `wd` is inside a git working tree (checked via
/// `git rev-parse --is-inside-work-tree`). Returns `false` on any
/// error or when stdout isn't the literal `"true"`.
fn is_git_repo(wd: &Path) -> bool {
    run_git(wd, &["rev-parse", "--is-inside-work-tree"])
        .map(|s| s.trim() == "true")
        .unwrap_or(false)
}

/// Run `git <args>` in `wd`, return trimmed stdout on exit-0. `None` on
/// any failure (git missing, non-zero exit, non-UTF8 output). stderr is
/// intentionally discarded — this is best-effort context enrichment and
/// error spam doesn't help the user.
fn run_git(wd: &Path, args: &[&str]) -> Option<String> {
    let mut cmd = Command::new("git");
    cmd.args(args)
        .current_dir(wd)
        // Suppress paging in case user has `pager.*` configured.
        .env("GIT_PAGER", "cat")
        .env("PAGER", "cat");
    crate::process_utils::suppress_console_window_sync(&mut cmd);
    let output = cmd.output().ok()?;
    if !output.status.success() {
        return None;
    }
    let s = String::from_utf8(output.stdout).ok()?;
    Some(s.trim().to_string())
}

/// Truncate `git status --short` output to at most `max_lines` lines.
/// When truncated, appends a summary line indicating how many were
/// omitted so the model doesn't think the repo is smaller than it is.
fn truncate_status(raw: &str, max_lines: usize) -> String {
    let lines: Vec<&str> = raw.lines().collect();
    if lines.len() <= max_lines {
        return raw.to_string();
    }
    let kept: Vec<&str> = lines.iter().take(max_lines).copied().collect();
    format!(
        "{}\n... and {} more line(s)\n",
        kept.join("\n"),
        lines.len() - max_lines
    )
}

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

    #[test]
    fn empty_snapshot_renders_nothing() {
        let snap = EnvSnapshot::default();
        assert_eq!(snap.as_prompt_section(), "");
    }

    #[test]
    fn capture_non_git_dir_returns_empty() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let snap = EnvSnapshot::capture(tmp.path());
        assert!(snap.git.is_none(), "non-git dir must yield git: None");
        assert_eq!(snap.as_prompt_section(), "");
    }

    #[test]
    fn snapshot_with_branch_renders_section() {
        let snap = EnvSnapshot {
            git: Some(GitSnapshot {
                branch: Some("main".into()),
                head_oneline: Some("abc1234 test commit".into()),
                status_short: String::new(),
                is_dirty: false,
            }),
        };
        let out = snap.as_prompt_section();
        assert!(out.contains("=== GIT STATUS"));
        assert!(out.contains("snapshot at session start"));
        assert!(out.contains("Branch: main"));
        assert!(out.contains("HEAD:   abc1234 test commit"));
        assert!(out.contains("Status: clean"));
        // Disclaimer at the end
        assert!(out.contains("Use `bash` + `git status` to check live state"));
    }

    #[test]
    fn detached_head_shown_explicitly() {
        let snap = EnvSnapshot {
            git: Some(GitSnapshot {
                branch: None, // detached
                head_oneline: Some("deadbee detached state".into()),
                status_short: String::new(),
                is_dirty: false,
            }),
        };
        let out = snap.as_prompt_section();
        assert!(out.contains("(detached HEAD)"));
    }

    #[test]
    fn dirty_status_includes_changes() {
        let snap = EnvSnapshot {
            git: Some(GitSnapshot {
                branch: Some("feat/x".into()),
                head_oneline: Some("abc1234 wip".into()),
                status_short: " M src/foo.rs\n M src/bar.rs\n?? new.rs".into(),
                is_dirty: true,
            }),
        };
        let out = snap.as_prompt_section();
        assert!(out.contains("Status: 3 change(s)"));
        assert!(out.contains(" M src/foo.rs"));
        assert!(out.contains("?? new.rs"));
        assert!(!out.contains("Status: clean"));
    }

    #[test]
    fn truncate_status_caps_long_output() {
        let raw = (0..50)
            .map(|i| format!(" M file_{}.rs", i))
            .collect::<Vec<_>>()
            .join("\n");
        let out = truncate_status(&raw, 20);
        let kept_lines = out.lines().filter(|l| l.starts_with(" M")).count();
        assert_eq!(kept_lines, 20);
        assert!(out.contains("... and 30 more line"));
    }

    #[test]
    fn truncate_status_passthrough_when_under_cap() {
        let raw = " M a.rs\n M b.rs";
        let out = truncate_status(raw, 20);
        assert_eq!(out, raw);
        assert!(!out.contains("more line"));
    }
}