//! Git sync for the vault.
//!
//! Only the operations a personal vault actually needs: read status, commit
//! everything, fast-forward pull, push. Nothing here can discard work — pull is
//! `--ff-only` (a divergence stops and reports instead of merging), push never
//! forces, and credential prompts are disabled so an automatic sync can never
//! hang waiting for input.

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

use serde::{Deserialize, Serialize};

use crate::vault::VaultError;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitStatus {
    pub is_repo: bool,
    pub branch: Option<String>,
    pub upstream: Option<String>,
    pub remote: Option<String>,
    pub ahead: u32,
    pub behind: u32,
    /// Number of changed/untracked entries.
    pub changes: u32,
    pub detail: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitOutcome {
    pub ok: bool,
    pub action: String,
    pub summary: String,
    pub detail: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitSyncResult {
    pub ok: bool,
    pub steps: Vec<GitOutcome>,
    pub status: GitStatus,
}

fn git(root: &Path, args: &[&str]) -> Result<std::process::Output, VaultError> {
    let mut command = Command::new("git");
    command.arg("-C").arg(root).args(args);
    command.env("GIT_TERMINAL_PROMPT", "0");
    command.env("GCM_INTERACTIVE", "never");
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // CREATE_NO_WINDOW: a background sync must not flash a console.
        command.creation_flags(0x0800_0000);
    }
    command.output().map_err(|problem| {
        VaultError(format!("无法执行 git(请确认已安装并加入 PATH): {problem}"))
    })
}

fn run(root: &Path, args: &[&str]) -> Result<(bool, String), VaultError> {
    let output = git(root, args)?;
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let text = if stdout.is_empty() { stderr } else { stdout };
    Ok((output.status.success(), text))
}

fn first_line(text: &str) -> String {
    text.lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or("")
        .to_string()
}

fn outcome(action: &str, ok: bool, fallback: &str, text: String) -> GitOutcome {
    let summary = first_line(&text);
    GitOutcome {
        ok,
        action: action.to_string(),
        summary: if summary.is_empty() {
            fallback.to_string()
        } else {
            summary
        },
        detail: text,
    }
}

fn parse_counts(text: &str) -> (u32, u32) {
    let mut parts = text.split_whitespace();
    let behind = parts.next().and_then(|value| value.parse().ok()).unwrap_or(0);
    let ahead = parts.next().and_then(|value| value.parse().ok()).unwrap_or(0);
    (ahead, behind)
}

pub fn status(root: &Path) -> GitStatus {
    let inside = match run(root, &["rev-parse", "--is-inside-work-tree"]) {
        Ok((true, text)) => text == "true",
        _ => false,
    };
    if !inside {
        return GitStatus {
            detail: Some("该知识库不是 Git 仓库".to_string()),
            ..GitStatus::default()
        };
    }

    // `symbolic-ref` also names the branch on a repository without commits yet.
    let branch = run(root, &["symbolic-ref", "--short", "HEAD"])
        .ok()
        .filter(|(ok, _)| *ok)
        .map(|(_, text)| text)
        .or_else(|| {
            run(root, &["rev-parse", "--abbrev-ref", "HEAD"])
                .ok()
                .filter(|(ok, _)| *ok)
                .map(|(_, text)| text)
        })
        .filter(|name| !name.is_empty() && name != "HEAD");

    let changes = run(root, &["status", "--porcelain"])
        .ok()
        .map(|(_, text)| text.lines().filter(|line| !line.trim().is_empty()).count() as u32)
        .unwrap_or(0);

    let upstream = run(
        root,
        &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
    )
    .ok()
    .filter(|(ok, _)| *ok)
    .map(|(_, text)| text)
    .filter(|name| !name.is_empty());

    let (ahead, behind) = match &upstream {
        Some(_) => run(
            root,
            &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
        )
        .ok()
        .filter(|(ok, _)| *ok)
        .map(|(_, text)| parse_counts(&text))
        .unwrap_or((0, 0)),
        None => (0, 0),
    };

    let remote = run(root, &["remote", "get-url", "origin"])
        .ok()
        .filter(|(ok, _)| *ok)
        .map(|(_, text)| text)
        .filter(|url| !url.is_empty());

    GitStatus {
        is_repo: true,
        branch,
        upstream,
        remote,
        ahead,
        behind,
        changes,
        detail: None,
    }
}

pub fn init(root: &Path) -> Result<GitOutcome, VaultError> {
    let (ok, text) = run(root, &["init"])?;
    Ok(outcome("init", ok, "已初始化仓库", text))
}

pub fn validate_remote(url: &str) -> Result<String, VaultError> {
    let trimmed = url.trim();
    let allowed = ["https://", "http://", "ssh://", "git@", "file://"];
    if trimmed.starts_with('-') || !allowed.iter().any(|prefix| trimmed.starts_with(prefix)) {
        return Err(VaultError(
            "远程地址需以 https:// / http:// / ssh:// / git@ / file:// 开头".to_string(),
        ));
    }
    Ok(trimmed.to_string())
}

pub fn set_remote(root: &Path, url: &str) -> Result<GitOutcome, VaultError> {
    let url = validate_remote(url)?;
    let has_origin = run(root, &["remote", "get-url", "origin"])
        .map(|(ok, _)| ok)
        .unwrap_or(false);
    let args: Vec<&str> = if has_origin {
        vec!["remote", "set-url", "origin", &url]
    } else {
        vec!["remote", "add", "origin", &url]
    };
    let (ok, text) = run(root, &args)?;
    Ok(outcome("remote", ok, "已设置远程地址", text))
}

/// Keep the rebuildable index out of `git add -A`.
///
/// Written to the repository's own `info/exclude` rather than the vault's
/// `.gitignore`, so we never touch a file the user tracks.
fn ensure_index_ignored(root: &Path) {
    const ENTRY: &str = ".tasklens/";
    let Ok((true, exclude_path)) = run(root, &["rev-parse", "--git-path", "info/exclude"]) else {
        return;
    };
    // `--git-path` may answer with a repo-relative path; resolve it against the
    // vault, not against whatever directory this process happens to run from.
    let reported = Path::new(exclude_path.trim());
    let exclude = if reported.is_absolute() {
        reported.to_path_buf()
    } else {
        root.join(reported)
    };
    let Ok(existing) = std::fs::read_to_string(&exclude) else {
        return;
    };
    if existing.lines().any(|line| line.trim() == ENTRY) {
        return;
    }
    let mut content = existing;
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    content.push_str("\n# TaskLens 索引库(派生数据,可删除重建)\n.tasklens/\n");
    let _ = std::fs::write(exclude, content);
}

/// Commit every change. Returns `ok` with a "nothing to do" summary when the
/// tree is already clean.
pub fn commit(root: &Path, message: &str) -> Result<GitOutcome, VaultError> {
    let message = message.trim();
    if message.is_empty() {
        return Err(VaultError("提交信息不能为空".to_string()));
    }
    ensure_index_ignored(root);
    let (ok, text) = run(root, &["status", "--porcelain"])?;
    if !ok {
        return Ok(outcome("commit", false, "无法读取仓库状态", text));
    }
    if text.lines().all(|line| line.trim().is_empty()) {
        return Ok(GitOutcome {
            ok: true,
            action: "commit".to_string(),
            summary: "没有需要提交的改动".to_string(),
            detail: String::new(),
        });
    }

    let (ok, text) = run(root, &["add", "-A"])?;
    if !ok {
        return Ok(outcome("commit", false, "暂存改动失败", text));
    }
    let (ok, text) = run(root, &["commit", "-m", message])?;
    Ok(outcome(
        "commit",
        ok,
        if ok { "已提交" } else { "提交失败" },
        text,
    ))
}

/// Refresh remote-tracking refs so `status().behind` reflects the remote.
pub fn fetch(root: &Path) -> Result<GitOutcome, VaultError> {
    if status(root).remote.is_none() {
        return Ok(GitOutcome {
            ok: false,
            action: "fetch".to_string(),
            summary: "尚未配置远程仓库".to_string(),
            detail: String::new(),
        });
    }
    let (ok, text) = run(root, &["fetch", "--prune"])?;
    Ok(outcome(
        "fetch",
        ok,
        if ok { "已获取远端状态" } else { "获取远端状态失败" },
        text,
    ))
}

pub fn pull(root: &Path) -> Result<GitOutcome, VaultError> {
    if status(root).upstream.is_none() {
        return Ok(GitOutcome {
            ok: false,
            action: "pull".to_string(),
            summary: "尚未设置上游分支:先推送一次即可建立".to_string(),
            detail: String::new(),
        });
    }
    let (ok, text) = run(root, &["pull", "--ff-only"])?;
    Ok(outcome(
        "pull",
        ok,
        if ok {
            "已是最新"
        } else {
            "无法快进合并:本地与远端已分叉,请手动处理"
        },
        text,
    ))
}

pub fn push(root: &Path) -> Result<GitOutcome, VaultError> {
    let current = status(root);
    if current.upstream.is_none() {
        let Some(branch) = current.branch.as_deref() else {
            return Ok(outcome("push", false, "当前不在分支上,无法推送", String::new()));
        };
        let (ok, text) = run(root, &["push", "-u", "origin", branch])?;
        return Ok(outcome(
            "push",
            ok,
            if ok { "已推送并设置上游" } else { "推送失败" },
            text,
        ));
    }
    let (ok, text) = run(root, &["push"])?;
    Ok(outcome(
        "push",
        ok,
        if ok { "已推送" } else { "推送失败" },
        text,
    ))
}

/// Commit → pull (fast-forward) → push, stopping at the first failure.
///
/// The first sync of a brand-new repository has no upstream yet, so pull is
/// skipped: pushing with `-u` establishes the upstream instead.
pub fn sync(root: &Path, message: &str) -> Result<GitSyncResult, VaultError> {
    let mut steps = Vec::new();

    let commit_outcome = commit(root, message)?;
    let commit_ok = commit_outcome.ok;
    steps.push(commit_outcome);
    if !commit_ok {
        return Ok(GitSyncResult {
            ok: false,
            steps,
            status: status(root),
        });
    }

    if status(root).upstream.is_some() {
        let pull_outcome = pull(root)?;
        let pull_ok = pull_outcome.ok;
        steps.push(pull_outcome);
        if !pull_ok {
            return Ok(GitSyncResult {
                ok: false,
                steps,
                status: status(root),
            });
        }
    }

    let push_outcome = push(root)?;
    let ok = push_outcome.ok;
    steps.push(push_outcome);

    Ok(GitSyncResult {
        ok,
        steps,
        status: status(root),
    })
}

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

    fn temp_dir() -> PathBuf {
        tempfile::tempdir().expect("temp dir").keep()
    }

    fn configure(root: &Path) {
        for (key, value) in [("user.email", "tasklens@example.com"), ("user.name", "TaskLens")] {
            let _ = Command::new("git")
                .arg("-C")
                .arg(root)
                .args(["config", key, value])
                .output();
        }
    }

    fn file_url(path: &Path) -> String {
        format!("file:///{}", path.to_string_lossy().replace('\\', "/"))
    }

    #[test]
    fn reports_a_directory_that_is_not_a_repository() {
        let root = temp_dir();
        let current = status(&root);
        assert!(!current.is_repo);
        assert_eq!(current.changes, 0);
        assert!(current.detail.is_some());
    }

    #[test]
    fn commits_and_reports_remaining_changes() {
        let root = temp_dir();
        git(&root, &["init"]).expect("git init");
        configure(&root);
        fs::write(root.join("note.md"), "# 一\n").expect("write");

        let current = status(&root);
        assert!(current.is_repo);
        assert_eq!(current.changes, 1);
        assert!(current.branch.is_some());

        let committed = commit(&root, "初始提交").expect("commit");
        assert!(committed.ok, "{committed:?}");
        assert_eq!(status(&root).changes, 0);

        let again = commit(&root, "初始提交").expect("commit");
        assert!(again.ok);
        assert_eq!(again.summary, "没有需要提交的改动");
    }

    #[test]
    fn rejects_unsafe_remote_urls() {
        assert!(validate_remote("https://example.com/vault.git").is_ok());
        assert!(validate_remote("file:///tmp/bare.git").is_ok());
        assert!(validate_remote("--upload-pack=touch evil").is_err());
        assert!(validate_remote("C:/somewhere").is_err());
        assert!(validate_remote("").is_err());
    }

    #[test]
    fn keeps_the_index_database_out_of_commits() {
        let root = temp_dir();
        git(&root, &["init"]).expect("git init");
        configure(&root);
        std::fs::create_dir_all(root.join(".tasklens")).expect("mkdir");
        std::fs::write(root.join(".tasklens/tasklens.db"), "binary").expect("write db");
        std::fs::write(root.join("note.md"), "# 一\n").expect("write note");

        let committed = commit(&root, "first").expect("commit");
        assert!(committed.ok, "{committed:?}");

        let (ok, tracked) = run(&root, &["ls-files"]).expect("ls-files");
        assert!(ok);
        assert!(tracked.contains("note.md"), "{tracked}");
        assert!(!tracked.contains(".tasklens"), "{tracked}");

        // The vault's own .gitignore is never rewritten.
        assert!(!root.join(".gitignore").exists());
    }

    #[test]
    fn pushes_pulls_against_a_local_remote() {
        let bare = temp_dir();
        assert!(run(&bare, &["init", "--bare"]).expect("init bare").0);

        let origin = temp_dir();
        assert!(run(&origin, &["init"]).expect("init origin").0);
        configure(&origin);
        fs::write(origin.join("a.md"), "一\n").expect("write a");
        let remote_url = file_url(&bare);
        assert!(set_remote(&origin, &remote_url).expect("set remote").ok);
        assert!(commit(&origin, "first").expect("commit").ok);

        let pushed = push(&origin).expect("push");
        assert!(pushed.ok, "{pushed:?}");

        let after = status(&origin);
        assert!(after.upstream.is_some());
        assert_eq!(after.ahead, 0);
        assert_eq!(after.behind, 0);

        // A second clone commits and pushes; the first repo must see it.
        let clone = temp_dir();
        let clone_target = clone.join("work");
        let output = Command::new("git")
            .args(["clone", &remote_url, &clone_target.to_string_lossy()])
            .output()
            .expect("clone");
        assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
        configure(&clone_target);
        fs::write(clone_target.join("b.md"), "二\n").expect("write b");
        assert!(commit(&clone_target, "second").expect("commit").ok);
        assert!(push(&clone_target).expect("push").ok);

        // behind only reflects the remote once the tracking ref is refreshed.
        assert_eq!(status(&origin).behind, 0);
        let fetched = fetch(&origin).expect("fetch");
        assert!(fetched.ok, "{fetched:?}");
        assert_eq!(status(&origin).behind, 1);

        let pulled = pull(&origin).expect("pull");
        assert!(pulled.ok, "{pulled:?}");
        assert!(origin.join("b.md").exists());
        assert_eq!(status(&origin).behind, 0);
    }

    #[test]
    fn sync_runs_commit_pull_push_in_order() {
        let bare = temp_dir();
        assert!(run(&bare, &["init", "--bare"]).expect("init bare").0);

        let root = temp_dir();
        assert!(run(&root, &["init"]).expect("init").0);
        configure(&root);
        fs::write(root.join("note.md"), "# 一\n").expect("write");
        assert!(set_remote(&root, &file_url(&bare)).expect("remote").ok);

        let result = sync(&root, "自动同步").expect("sync");
        assert!(result.ok, "{result:?}");
        // First sync: no upstream yet, so pull is skipped and push sets it.
        let actions: Vec<&str> = result.steps.iter().map(|step| step.action.as_str()).collect();
        assert_eq!(actions, vec!["commit", "push"]);
        assert_eq!(result.status.changes, 0);
        assert_eq!(result.status.ahead, 0);

        // With an upstream in place, sync runs the full sequence.
        fs::write(root.join("note.md"), "# 二\n").expect("write");
        let result = sync(&root, "自动同步").expect("sync");
        assert!(result.ok, "{result:?}");
        let actions: Vec<&str> = result.steps.iter().map(|step| step.action.as_str()).collect();
        assert_eq!(actions, vec!["commit", "pull", "push"]);
        assert_eq!(result.status.ahead, 0);
    }
}