/* git.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 std::process::Command;

mod archive;
mod commit;
mod diff;
mod helpers;
mod merge;
mod rebase;
mod refs;
mod remote_ops;
mod search;
mod stash;
mod status;
mod todo;
mod types;

#[cfg(test)]
mod test_helpers;

pub use archive::create_archive;
pub use commit::{
    amend_commit, create_commit, get_head_commit_message, load_commit_diff, load_commit_message, load_commits_page,
};
pub use diff::{discard_hunk, discard_lines, load_file_diff, stage_hunk, stage_lines, unstage_hunk, unstage_lines};
pub use helpers::{TagSortConfig, TagSortDirection, TagSortMethod};
pub use merge::{merge_branch, read_default_merge_strategy_from_config};
pub use rebase::{
    SquashMode, drop_commit, edit_commit, head_rewriteable_oids, load_squash_messages, rebase_abort,
    rebase_branch_or_oid, rebase_continue, rebase_onto, reorder_commits, reword_commit, squash_commits,
};
pub use refs::{
    branch_exists, build_branch_oid_map, build_tag_oid_map, checkout_branch, checkout_detached, create_annotated_tag,
    create_branch_from, create_lightweight_tag, delete_branch, delete_tag, get_commit_summary, head_upstream_status,
    is_branch_merged, load_branch_info, load_tag_info, rename_branch, resolve_tip_oid, resolve_upstream_oid,
    upstream_remote,
};
pub use remote_ops::{
    clone_repository, fetch, fetch_all, get_pull_defaults, get_push_defaults, list_remote_names, list_ssh_remotes,
    pull_with_autostash, push, push_tag,
};
pub use search::search_commits;
pub use stash::{load_stash_diff, load_stashes_from_path, stash_apply, stash_drop, stash_pop, stash_push};
pub use status::{
    add_to_gitignore, delete_file, delete_files, dirty_summary, discard_file, discard_files, load_status_from_path,
    quick_status, stage_all, stage_file, stage_files, unstage_all, unstage_file, unstage_files,
};
pub use todo::{
    MergeKind, MergeSource, Op, Todo, TodoContext, TodoOutcome, abort as abort_todo,
    discard_if_present as discard_todo, pending_source_ref, reconcile as reconcile_todo, run as run_todo,
};
pub use types::*;

use crate::{askpass, config, preferences};

fn git_err(context: &str) -> impl FnOnce(git2::Error) -> String + '_ {
    move |e| format!("{context}: {e}")
}

pub fn git_command(cwd: Option<&Path>, argv: &[String], ssh_key: Option<&Path>) -> Command {
    let (program, prefix_args): (&str, &[String]) = match argv.split_first() {
        Some((first, rest)) => (first.as_str(), rest),
        None => ("git", &[]),
    };

    let mut cmd = Command::new(program);

    for arg in prefix_args {
        cmd.arg(arg);
    }

    if let Some(p) = cwd {
        cmd.current_dir(p);
    }

    cmd.env("LC_CTYPE", "C.UTF-8");
    cmd.env("GIT_TERMINAL_PROMPT", "0");
    apply_host_git_config(&mut cmd);
    apply_askpass(&mut cmd);
    cmd.arg("-c").arg("commit.gpgsign=false");
    cmd.arg("--no-pager");

    if preferences::bypass_local_git_config() {
        apply_bypass_local_git_config(&mut cmd, cwd);
    }

    if let Some(key) = ssh_key {
        let escaped = key.to_string_lossy().replace('\'', "'\\''");
        cmd.env("GIT_SSH_COMMAND", format!("ssh -i '{escaped}' -o IdentitiesOnly=yes"));
    }

    cmd
}

fn apply_askpass(cmd: &mut Command) {
    let Some(server) = askpass::server::current() else {
        tracing::warn!("askpass: server not running, git will not have credential helper");
        return;
    };

    let Some(askpass_path) = askpass_binary_path() else {
        tracing::warn!(
            "askpass: helper binary not found (tried {:?} and {:?})",
            config::ASKPASS_PATH,
            std::env::current_exe()
                .ok()
                .and_then(|e| e.parent().map(|p| p.join("gitte-askpass")))
        );

        return;
    };

    let token = match server.register_spawn() {
        Ok(t) => t,

        Err(e) => {
            tracing::warn!("askpass: failed to mint token: {e}");
            return;
        }
    };

    tracing::info!("askpass: enabling helper at {:?}, socket {:?}", askpass_path, server.socket_path());

    cmd.env("GIT_ASKPASS", &askpass_path);
    cmd.env("SSH_ASKPASS", &askpass_path);
    cmd.env("SSH_ASKPASS_REQUIRE", "force");
    cmd.env(askpass::ipc::SOCKET_ENV, server.socket_path());
    cmd.env(askpass::ipc::TOKEN_ENV, token);
}

fn askpass_binary_path() -> Option<std::path::PathBuf> {
    let install = std::path::PathBuf::from(config::ASKPASS_PATH);

    if install.is_file() {
        return Some(install);
    }

    if let Ok(exe) = std::env::current_exe()
        && let Some(dir) = exe.parent()
    {
        let sibling = dir.join("gitte-askpass");

        if sibling.is_file() {
            return Some(sibling);
        }
    }

    None
}

fn apply_bypass_local_git_config(cmd: &mut Command, cwd: Option<&Path>) {
    cmd.arg("-c").arg("credential.helper=");
    cmd.arg("-c").arg("core.hooksPath=/dev/null");

    let Some(cwd) = cwd else { return };
    let Ok(repo) = git2::Repository::open(cwd) else { return };
    let Ok(config) = repo.config() else { return };
    let Ok(mut entries) = config.entries(Some("credential\\..*\\.helper")) else {
        return;
    };

    while let Some(Ok(entry)) = entries.next() {
        if let Some(name) = entry.name() {
            cmd.arg("-c").arg(format!("{name}="));
        }
    }
}

fn apply_host_git_config(cmd: &mut Command) {
    let host_system = Path::new("/run/host/etc/gitconfig");
    if host_system.exists() {
        cmd.env("GIT_CONFIG_SYSTEM", host_system);
    }

    if let Some(home) = dirs::home_dir() {
        let xdg = home.join(".config/git/config");
        let legacy = home.join(".gitconfig");

        if xdg.exists() {
            cmd.env("GIT_CONFIG_GLOBAL", xdg);
        } else if legacy.exists() {
            cmd.env("GIT_CONFIG_GLOBAL", legacy);
        }
    }
}

pub fn strip_trailing_newline(s: &str) -> &str {
    s.strip_suffix("\r\n").or_else(|| s.strip_suffix("\n")).unwrap_or(s)
}

pub fn is_valid_branch_name(name: &str) -> bool {
    git2::Reference::is_valid_name(&format!("refs/heads/{name}"))
}

pub fn is_valid_tag_name(name: &str) -> bool {
    git2::Reference::is_valid_name(&format!("refs/tags/{name}"))
}

pub fn is_git_repository(path: &Path) -> bool {
    git2::Repository::open(path).is_ok()
}

pub fn init_repository(path: &Path) -> Result<(), String> {
    let default_branch = git2::Config::open_default()
        .ok()
        .and_then(|cfg| cfg.get_string("init.defaultBranch").ok())
        .unwrap_or_else(|| "main".to_string());

    let mut opts = git2::RepositoryInitOptions::new();
    opts.initial_head(&default_branch);

    git2::Repository::init_opts(path, &opts)
        .map(|_| ())
        .map_err(git_err("Failed to initialize repository"))
}

fn open_repo(path: &Path) -> Result<git2::Repository, String> {
    let repo = git2::Repository::open(path).map_err(git_err("Failed to open repository"))?;

    // In Flatpak builds, check /run/host/etc/gitconfig, which is where Flatpak exposes the
    // host system's /etc/gitconfig. Explicitly add it so that system-level settings
    // (like user.name / user.email) are found.
    let path = Path::new("/run/host/etc/gitconfig");
    if path.exists()
        && let Ok(mut config) = repo.config()
    {
        let _ = config.add_file(path, git2::ConfigLevel::System, false);
    }

    // In Flatpak builds, XDG_CONFIG_HOME is overridden to a sandbox path, so libgit2's
    // git_config_find_xdg() misses the user's real ~/.config/git/config. With
    // --filesystem=home, $HOME still points at the host home, so resolve it explicitly.
    if let Some(home) = dirs::home_dir() {
        let xdg_config = home.join(".config/git/config");

        if xdg_config.exists()
            && let Ok(mut config) = repo.config()
        {
            let _ = config.add_file(&xdg_config, git2::ConfigLevel::XDG, false);
        }
    }

    Ok(repo)
}

pub fn load_git_data(path: &Path, tag_sort: TagSortConfig) -> Result<GitData, String> {
    let _ = todo::reconcile(path);

    let mut repo = open_repo(path)?;

    let mut tags = refs::load_tags(&repo);
    helpers::sort_tags(&repo, &mut tags, &tag_sort);

    let (head_branch, head_kind) = match repo.head() {
        Ok(head) if head.is_branch() => (head.shorthand().map(str::to_string), HeadKind::Branch),

        Ok(head) => {
            let head_oid = head.target();
            let tag_oid_map = refs::build_tag_oid_map_from_repo(&repo);
            let tag_name = head_oid.and_then(|oid| {
                tag_oid_map
                    .get(&oid.to_string())
                    .and_then(|names| names.first().cloned())
            });

            match (tag_name, head_oid) {
                (Some(name), _) => (Some(name), HeadKind::Tag),

                (None, Some(oid)) => (
                    repo.find_object(oid, None)
                        .and_then(|obj| obj.short_id())
                        .ok()
                        .and_then(|buf| buf.as_str().map(str::to_string)),
                    HeadKind::Detached,
                ),

                (None, None) => (None, HeadKind::Detached),
            }
        }

        Err(_) => (None, HeadKind::Detached),
    };

    let repo_state = RepoState::from(repo.state());

    let rebase_has_conflicts = if matches!(repo_state, RepoState::Rebase) {
        repo.index()
            .and_then(|mut i| {
                i.read(true)?;
                Ok(i.has_conflicts())
            })
            .unwrap_or(false)
    } else {
        false
    };

    let rewriteable_oids = head_rewriteable_oids(path).unwrap_or_default();

    let head_upstream = match (head_kind, head_branch.as_deref()) {
        (HeadKind::Branch, Some(name)) => refs::head_upstream_status(&repo, name),
        _ => None,
    };

    Ok(GitData {
        branches: refs::load_branches(&repo),
        head_branch,
        head_kind,
        head_upstream,
        tags,
        remotes: refs::load_remotes(&repo),
        stashes: stash::load_stashes(&mut repo),
        repo_state,
        rebase_has_conflicts,
        rewriteable_oids,
    })
}
