/* git/status.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::path::Path;

use super::types::*;
use super::{git_err, open_repo};

#[cfg(test)]
mod tests;

/*
 * Cheap, side-effect-free status summary for the start screen badges.
 *
 * Unlike `load_status`, this never returns an error: a path that no longer
 * points at a valid repository becomes `QuickStatus { valid: false, .. }` so
 * the caller can render a "missing" state without a separate error path.
 */
pub fn quick_status(path: &Path) -> QuickStatus {
    let Ok(repo) = git2::Repository::open(path) else {
        return QuickStatus {
            branch: None,
            dirty: false,
            valid: false,
        };
    };

    let is_bare = repo.is_bare();

    // Bare repos have no working tree, so a "current branch" is not
    // meaningful for the start screen badge, leave it empty.
    let branch = if is_bare {
        None
    } else {
        repo.head().ok().and_then(|r| r.shorthand().map(String::from))
    };

    let dirty = if is_bare {
        false
    } else {
        let mut opts = git2::StatusOptions::new();

        opts.include_ignored(false)
            .include_untracked(true)
            .recurse_untracked_dirs(true);

        repo.statuses(Some(&mut opts))
            .map(|statuses| statuses.iter().any(|e| !e.status().is_empty()))
            .unwrap_or(false)
    };

    QuickStatus {
        branch,
        dirty,
        valid: true,
    }
}

pub fn dirty_summary(path: &Path) -> Result<DirtySummary, String> {
    let repo = open_repo(path)?;
    dirty_summary_repo(&repo)
}

pub(super) fn dirty_summary_repo(repo: &git2::Repository) -> Result<DirtySummary, String> {
    let mut opts = git2::StatusOptions::new();

    opts.include_ignored(false)
        .include_untracked(true)
        .recurse_untracked_dirs(true);

    let statuses = repo
        .statuses(Some(&mut opts))
        .map_err(|e| format!("Failed to read status: {e}"))?;

    let mut summary = DirtySummary::default();

    for entry in statuses.iter() {
        let status = entry.status();

        if status.is_empty() {
            continue;
        }

        if status.contains(git2::Status::WT_NEW) {
            summary.has_untracked = true;
        } else {
            summary.has_tracked_changes = true;
        }

        if summary.has_tracked_changes && summary.has_untracked {
            break;
        }
    }

    Ok(summary)
}

pub fn load_status_from_path(path: &Path) -> Result<Vec<ChangedFile>, String> {
    let repo = open_repo(path)?;
    Ok(load_status(&repo))
}

pub(super) fn load_status(repo: &git2::Repository) -> Vec<ChangedFile> {
    let mut opts = git2::StatusOptions::new();

    opts.include_ignored(false)
        .include_untracked(true)
        .recurse_untracked_dirs(true);

    let mut files = Vec::new();
    if let Ok(statuses) = repo.statuses(Some(&mut opts)) {
        for entry in statuses.iter() {
            let status = entry.status();
            if status.is_empty() {
                continue;
            }

            let path = entry.path().unwrap_or("?").to_string();

            if status.contains(git2::Status::CONFLICTED) {
                files.push(ChangedFile {
                    path,
                    index_status: None,
                    wt_status: Some(FileStatus::Conflicted),
                });

                continue;
            }

            let index_status = extract_status(status, INDEX_FLAGS);
            let wt_status = extract_status(status, WT_FLAGS);

            if index_status.is_some() || wt_status.is_some() {
                files.push(ChangedFile {
                    path,
                    index_status,
                    wt_status,
                });
            }
        }
    }

    files
}

fn extract_status(status: git2::Status, flags: &[(git2::Status, FileStatus)]) -> Option<FileStatus> {
    flags.iter().find(|(flag, _)| status.contains(*flag)).map(|(_, fs)| *fs)
}

pub fn stage_file(repo_path: &Path, file_path: &str, wt_status: Option<FileStatus>) -> Result<(), String> {
    let repo = open_repo(repo_path)?;
    let mut index = repo.index().map_err(git_err("Failed to open index"))?;

    if wt_status == Some(FileStatus::Deleted) {
        index.remove_path(Path::new(file_path))
    } else {
        index.add_path(Path::new(file_path))
    }
    .map_err(|e| format!("Failed to stage file {file_path}: {e}"))?;

    index.write().map_err(git_err("Failed to write index"))
}

pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
    let repo = open_repo(repo_path)?;
    let head = repo.head().ok().and_then(|r| r.peel_to_commit().ok());

    match head {
        Some(commit) => repo
            .reset_default(Some(commit.as_object()), [file_path])
            .map_err(|e| format!("Failed to unstage file {file_path}: {e}")),

        None => {
            let mut index = repo.index().map_err(git_err("Failed to open index"))?;
            index
                .remove_path(Path::new(file_path))
                .map_err(|e| format!("Failed to unstage file {file_path}: {e}"))?;

            index.write().map_err(git_err("Failed to write index"))
        }
    }
}

pub fn stage_files(repo_path: &Path, files: &[(String, Option<FileStatus>)]) -> Result<(), String> {
    if files.is_empty() {
        return Ok(());
    }

    let repo = open_repo(repo_path)?;
    let mut index = repo.index().map_err(git_err("Failed to open index"))?;

    for (file_path, wt_status) in files {
        if *wt_status == Some(FileStatus::Deleted) {
            index.remove_path(Path::new(file_path))
        } else {
            index.add_path(Path::new(file_path))
        }
        .map_err(|e| format!("Failed to stage file {file_path}: {e}"))?;
    }

    index.write().map_err(git_err("Failed to write index"))
}

pub fn unstage_files(repo_path: &Path, file_paths: &[&str]) -> Result<(), String> {
    if file_paths.is_empty() {
        return Ok(());
    }

    let repo = open_repo(repo_path)?;
    let head = repo.head().ok().and_then(|r| r.peel_to_commit().ok());

    match head {
        Some(commit) => repo
            .reset_default(Some(commit.as_object()), file_paths.iter().copied())
            .map_err(|e| format!("Failed to unstage files: {e}")),

        None => {
            let mut index = repo.index().map_err(git_err("Failed to open index"))?;

            for fp in file_paths {
                index
                    .remove_path(Path::new(fp))
                    .map_err(|e| format!("Failed to unstage file {fp}: {e}"))?;
            }

            index.write().map_err(git_err("Failed to write index"))
        }
    }
}

pub fn discard_files(repo_path: &Path, files: &[(String, Option<FileStatus>)]) -> Result<(), String> {
    for (file_path, index_status) in files {
        discard_file(repo_path, file_path, *index_status)?;
    }

    Ok(())
}

pub fn delete_files(repo_path: &Path, files: &[(String, Option<FileStatus>)]) -> Result<(), String> {
    for (file_path, index_status) in files {
        delete_file(repo_path, file_path, *index_status)?;
    }

    Ok(())
}

pub fn stage_all(repo_path: &Path) -> Result<(), String> {
    let repo = open_repo(repo_path)?;
    let mut index = repo.index().map_err(git_err("Failed to open index"))?;

    index
        .add_all(["*"], git2::IndexAddOption::DEFAULT, None)
        .map_err(git_err("Failed to stage files"))?;

    // Also handle deleted files
    index
        .update_all(["*"], None)
        .map_err(git_err("Failed to update index for deleted files"))?;

    index.write().map_err(git_err("Failed to write index"))
}

pub fn unstage_all(repo_path: &Path) -> Result<(), String> {
    let repo = open_repo(repo_path)?;
    let head = repo.head().ok().and_then(|r| r.peel_to_commit().ok());

    match head {
        Some(commit) => repo
            .reset_default(Some(commit.as_object()), ["*"])
            .map_err(git_err("Failed to unstage files")),

        None => {
            let mut index = repo.index().map_err(git_err("Failed to open index"))?;
            index.clear().map_err(git_err("Failed to clear index"))?;
            index.write().map_err(git_err("Failed to write index"))
        }
    }
}

fn file_exists_in_head(repo: &git2::Repository, file_path: &str) -> bool {
    repo.head()
        .ok()
        .and_then(|r| r.peel_to_commit().ok())
        .and_then(|c| c.tree().ok())
        .and_then(|t| t.get_path(Path::new(file_path)).ok())
        .is_some()
}

fn remove_file_if_exists(abs: &Path, file_path: &str) -> Result<(), String> {
    match std::fs::remove_file(abs) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(format!("Failed to delete {file_path}: {e}")),
    }
}

pub fn discard_file(repo_path: &Path, file_path: &str, index_status: Option<FileStatus>) -> Result<(), String> {
    let repo = open_repo(repo_path)?;

    if file_exists_in_head(&repo, file_path) {
        if index_status.is_some()
            && let Some(commit) = repo.head().ok().and_then(|r| r.peel_to_commit().ok())
        {
            repo.reset_default(Some(commit.as_object()), [file_path])
                .map_err(|e| format!("Failed to reset {file_path}: {e}"))?;
        }

        let mut opts = git2::build::CheckoutBuilder::new();
        opts.force().path(file_path);

        repo.checkout_head(Some(&mut opts))
            .map_err(|e| format!("Failed to discard changes in {file_path}: {e}"))?;
    } else {
        if index_status.is_some() {
            let mut index = repo.index().map_err(git_err("Failed to open index"))?;

            index
                .remove_path(Path::new(file_path))
                .map_err(|e| format!("Failed to unstage {file_path}: {e}"))?;

            index.write().map_err(git_err("Failed to write index"))?;
        }

        remove_file_if_exists(&repo_path.join(file_path), file_path)?;
    }

    Ok(())
}

pub fn delete_file(repo_path: &Path, file_path: &str, index_status: Option<FileStatus>) -> Result<(), String> {
    let repo = open_repo(repo_path)?;

    remove_file_if_exists(&repo_path.join(file_path), file_path)?;

    let is_tracked = index_status.is_some() || file_exists_in_head(&repo, file_path);

    if is_tracked {
        let mut index = repo.index().map_err(git_err("Failed to open index"))?;

        index
            .remove_path(Path::new(file_path))
            .map_err(|e| format!("Failed to stage deletion of {file_path}: {e}"))?;

        index.write().map_err(git_err("Failed to write index"))?;
    }

    Ok(())
}

pub fn add_to_gitignore(repo_path: &Path, pattern: &str) -> Result<(), String> {
    let gitignore_path = repo_path.join(".gitignore");

    let mut contents = match std::fs::read_to_string(&gitignore_path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(e) => return Err(format!("Failed to read .gitignore: {e}")),
    };

    if !contents.ends_with('\n') && !contents.is_empty() {
        contents.push('\n');
    }

    contents.push_str(pattern);
    contents.push('\n');

    std::fs::write(&gitignore_path, contents).map_err(|e| format!("Failed to update .gitignore: {e}"))
}
