//! Git operations for FURNACE scroll commits.
//!
//! Uses `tokio::process::Command` to shell out to `git`, avoiding the `git2`
//! crate and its C dependency chain (cross-compilation friendly).

use std::path::PathBuf;

use anyhow::{Context, Result};
use tracing::{debug, info};

use crate::types::Task;

// ---------------------------------------------------------------------------
// GitOps
// ---------------------------------------------------------------------------

/// Thin wrapper around git CLI for staging and committing scroll changes.
pub struct GitOps {
    workspace_root: PathBuf,
}

impl GitOps {
    pub fn new(workspace_root: PathBuf) -> Self {
        Self { workspace_root }
    }

    /// Check whether the workspace root is inside a git repository.
    pub async fn is_git_repo(&self) -> bool {
        tokio::process::Command::new("git")
            .args(["rev-parse", "--is-inside-work-tree"])
            .current_dir(&self.workspace_root)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .await
            .map(|s| s.success())
            .unwrap_or(false)
    }

    /// Stage the given files (workspace-relative paths).
    pub async fn stage_files(&self, files: &[String]) -> Result<()> {
        if files.is_empty() {
            return Ok(());
        }

        let output = tokio::process::Command::new("git")
            .arg("add")
            .arg("--")
            .args(files)
            .current_dir(&self.workspace_root)
            .output()
            .await
            .context("Failed to run git add")?;

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

        debug!(count = files.len(), "Staged files for commit");
        Ok(())
    }

    /// Commit with the given message. Returns the short commit hash.
    pub async fn commit(&self, message: &str) -> Result<String> {
        let output = tokio::process::Command::new("git")
            .args(["commit", "-m", message])
            .current_dir(&self.workspace_root)
            .output()
            .await
            .context("Failed to run git commit")?;

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

        // Extract the commit hash from `git rev-parse --short HEAD`.
        let hash_output = tokio::process::Command::new("git")
            .args(["rev-parse", "--short", "HEAD"])
            .current_dir(&self.workspace_root)
            .output()
            .await
            .context("Failed to get commit hash")?;

        let hash = String::from_utf8_lossy(&hash_output.stdout)
            .trim()
            .to_string();

        info!(hash = %hash, "Git commit created");
        Ok(hash)
    }

    /// Check whether there are any staged changes ready to commit.
    pub async fn has_staged_changes(&self) -> bool {
        tokio::process::Command::new("git")
            .args(["diff", "--cached", "--quiet"])
            .current_dir(&self.workspace_root)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .await
            .map(|s| !s.success()) // exit 1 = there ARE staged changes
            .unwrap_or(false)
    }
}

// ---------------------------------------------------------------------------
// Commit message helpers
// ---------------------------------------------------------------------------

/// Infer a conventional-commit type from the scroll intent.
pub fn infer_commit_type(intent: &str) -> &'static str {
    let lower = intent.to_lowercase();

    if lower.contains("fix") || lower.contains("bug") || lower.contains("patch") {
        "fix"
    } else if lower.contains("refactor") || lower.contains("restructure") {
        "refactor"
    } else if lower.contains("test") {
        "test"
    } else if lower.contains("doc") || lower.contains("readme") {
        "docs"
    } else if lower.contains("chore") || lower.contains("cleanup") || lower.contains("clean up") {
        "chore"
    } else {
        "feat"
    }
}

/// Build a conventional commit message from scroll metadata.
///
/// Format:
/// ```text
/// feat(SCR-abc123): Short intent description
///
/// Tasks:
/// - [TASK-001] [group] Created src/foo.rs — Description
///
/// Scroll: SCR-abc123
/// Co-Authored-By: FURNACE
/// ```
pub fn build_commit_message(
    scroll_id: &str,
    intent: &str,
    tasks: &[Task],
    commit_type_override: Option<&str>,
) -> String {
    let commit_type = commit_type_override.unwrap_or_else(|| infer_commit_type(intent));

    // Short scroll ID suffix for the scope.
    let short_id = if scroll_id.len() > 10 {
        &scroll_id[scroll_id.len() - 8..]
    } else {
        scroll_id
    };

    // Subject line: truncate intent to ~50 chars (char-safe to avoid UTF-8 panic).
    let subject = if intent.chars().count() > 50 {
        let truncated: String = intent.chars().take(47).collect();
        format!("{}...", truncated)
    } else {
        intent.to_string()
    };

    let mut message = format!("{}(SCR-{}): {}\n", commit_type, short_id, subject);

    // Task listing.
    if !tasks.is_empty() {
        message.push_str("\nTasks:\n");
        for task in tasks {
            let action = match task.task_type {
                crate::types::TaskType::Create => "Created",
                crate::types::TaskType::Modify => "Modified",
                crate::types::TaskType::Delete => "Deleted",
                crate::types::TaskType::Test => "Tested",
            };
            let group_tag = task
                .group_label
                .as_deref()
                .map(|g| format!(" [{}]", g))
                .unwrap_or_default();
            let short_desc: String = task.description.chars().take(60).collect();
            message.push_str(&format!(
                "- [{}]{} {} {} — {}\n",
                task.id, group_tag, action, task.target_file, short_desc
            ));
        }
    }

    message.push_str(&format!("\nScroll: {}\n", scroll_id));
    message.push_str("Co-Authored-By: FURNACE\n");

    message
}

// ===========================================================================
// Tests
// ===========================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Task, TaskStatus, TaskType};

    #[test]
    fn test_infer_commit_type_fix() {
        assert_eq!(infer_commit_type("Fix the login bug"), "fix");
        assert_eq!(infer_commit_type("bugfix for auth"), "fix");
        assert_eq!(infer_commit_type("patch the endpoint"), "fix");
    }

    #[test]
    fn test_infer_commit_type_feat() {
        assert_eq!(infer_commit_type("Add authentication module"), "feat");
        assert_eq!(infer_commit_type("Implement new dashboard"), "feat");
    }

    #[test]
    fn test_infer_commit_type_refactor() {
        assert_eq!(infer_commit_type("Refactor the auth system"), "refactor");
        assert_eq!(infer_commit_type("Restructure database layer"), "refactor");
    }

    #[test]
    fn test_infer_commit_type_test() {
        assert_eq!(infer_commit_type("Add tests for auth module"), "test");
    }

    #[test]
    fn test_infer_commit_type_docs() {
        assert_eq!(infer_commit_type("Update documentation"), "docs");
        assert_eq!(infer_commit_type("Update README"), "docs");
    }

    #[test]
    fn test_infer_commit_type_chore() {
        assert_eq!(infer_commit_type("Chore: update deps"), "chore");
        assert_eq!(infer_commit_type("Cleanup unused imports"), "chore");
    }

    #[test]
    fn test_build_commit_message_basic() {
        let tasks = vec![
            Task {
                id: "TASK-001".into(),
                scroll_id: "SCROLL-123456".into(),
                description: "Create auth handler".into(),
                target_file: "src/auth/mod.rs".into(),
                task_type: TaskType::Create,
                status: TaskStatus::Completed,
                dependencies: vec![],
                generated_code: None,
                diff: None,
                created_at: chrono::Utc::now(),
                group_label: Some("auth-module".into()),
            },
            Task {
                id: "TASK-002".into(),
                scroll_id: "SCROLL-123456".into(),
                description: "Add auth tests".into(),
                target_file: "src/auth/tests.rs".into(),
                task_type: TaskType::Test,
                status: TaskStatus::Completed,
                dependencies: vec![],
                generated_code: None,
                diff: None,
                created_at: chrono::Utc::now(),
                group_label: Some("auth-module".into()),
            },
        ];

        let msg = build_commit_message(
            "SCROLL-123456",
            "Add authentication module",
            &tasks,
            None,
        );

        assert!(msg.starts_with("feat(SCR-"));
        assert!(msg.contains("Add authentication module"));
        assert!(msg.contains("[TASK-001]"));
        assert!(msg.contains("[auth-module]"));
        assert!(msg.contains("Created src/auth/mod.rs"));
        assert!(msg.contains("Tested src/auth/tests.rs"));
        assert!(msg.contains("Co-Authored-By: FURNACE"));
        assert!(msg.contains("Scroll: SCROLL-123456"));
    }

    #[test]
    fn test_build_commit_message_override_type() {
        let msg = build_commit_message(
            "SCROLL-999",
            "Add something new",
            &[],
            Some("fix"),
        );
        assert!(msg.starts_with("fix(SCR-"));
    }

    #[test]
    fn test_build_commit_message_long_intent_truncated() {
        let long_intent = "A very long intent description that goes on and on and should be truncated to fit the subject line";
        let msg = build_commit_message("SCROLL-999", long_intent, &[], None);
        let first_line = msg.lines().next().unwrap();
        // Subject line should be reasonable length
        assert!(first_line.len() < 80, "Subject too long: {}", first_line.len());
        assert!(first_line.contains("..."));
    }

    #[test]
    fn test_build_commit_message_multibyte_intent_no_panic() {
        // Multi-byte UTF-8: each emoji is 4 bytes. 60 chars of emoji > 50 chars triggers truncation.
        let emoji_intent = "🔥".repeat(60);
        let msg = build_commit_message("SCROLL-999", &emoji_intent, &[], None);
        let first_line = msg.lines().next().unwrap();
        assert!(first_line.contains("..."));
        // CJK characters (3 bytes each)
        let cjk_intent = "日本語のテストで長い説明文を書きます。これは五十文字以上のテスト用インテントです。切り捨てが正しく動作するか確認";
        let msg2 = build_commit_message("SCROLL-999", cjk_intent, &[], None);
        let first_line2 = msg2.lines().next().unwrap();
        assert!(first_line2.contains("..."));
    }

    #[test]
    fn test_build_commit_message_no_group_label() {
        let tasks = vec![Task {
            id: "TASK-001".into(),
            scroll_id: "SCROLL-123".into(),
            description: "Modify config".into(),
            target_file: "src/config.rs".into(),
            task_type: TaskType::Modify,
            status: TaskStatus::Completed,
            dependencies: vec![],
            generated_code: None,
            diff: None,
            created_at: chrono::Utc::now(),
            group_label: None,
        }];

        let msg = build_commit_message("SCROLL-123", "Update config", &tasks, None);
        assert!(msg.contains("[TASK-001] Modified src/config.rs"));
        // No group tag should appear
        assert!(!msg.contains("[]"));
    }

    #[tokio::test]
    async fn test_git_ops_is_git_repo() {
        // The furnace workspace itself is a git repo
        let ops = GitOps::new(PathBuf::from("/Users/gyan/furnace"));
        assert!(ops.is_git_repo().await);
    }

    #[tokio::test]
    async fn test_git_ops_not_git_repo() {
        let ops = GitOps::new(PathBuf::from("/tmp"));
        // /tmp is unlikely to be a git repo
        // This test might be flaky in CI but works locally
        let result = ops.is_git_repo().await;
        // We don't assert false because /tmp might be inside a repo in some setups
        // Just verify it doesn't panic
        let _ = result;
    }
}
