/* git/refs.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::collections::HashMap;
use std::path::Path;

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

pub const BRANCH_COMMIT_COUNT_CAP: usize = 5000;

pub fn head_upstream_status(repo: &git2::Repository, branch_name: &str) -> Option<HeadUpstreamStatus> {
    let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
    let local_oid = branch.get().peel_to_commit().ok()?.id();
    let upstream_oid = branch.upstream().ok()?.get().peel_to_commit().ok()?.id();
    let (ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid).ok()?;

    Some(HeadUpstreamStatus { ahead, behind })
}

pub fn branch_exists(path: &Path, name: &str, kind: BranchKind) -> bool {
    let Ok(repo) = open_repo(path) else {
        return false;
    };

    repo.find_branch(name, kind.into()).is_ok()
}

pub fn load_branch_info(path: &Path, branch_name: &str, kind: BranchKind, is_head: bool) -> Result<BranchInfo, String> {
    let repo = open_repo(path)?;

    let branch = repo
        .find_branch(branch_name, kind.into())
        .map_err(git_err("Failed to find branch"))?;

    let commit = branch
        .get()
        .peel_to_commit()
        .map_err(git_err("Failed to peel branch to commit"))?;

    let message = commit.message().unwrap_or("");
    let latest_commit_summary = message.lines().next().unwrap_or("").to_string();
    let latest_commit_author = commit.author().name().unwrap_or("Unknown").to_string();
    let latest_commit_time = commit.author().when().seconds();

    let (upstream_name, ahead, behind) = match branch.upstream() {
        Ok(upstream) => {
            let name = upstream.name().ok().flatten().map(str::to_string);
            let upstream_oid = upstream.get().peel_to_commit().map(|c| c.id()).ok();

            let (a, b) = match upstream_oid {
                Some(uid) => repo.graph_ahead_behind(commit.id(), uid).unwrap_or((0, 0)),
                None => (0, 0),
            };

            (name, a, b)
        }
        Err(_) => (None, 0, 0),
    };

    let (total_commits, total_commits_capped) = {
        let mut revwalk = repo.revwalk().map_err(git_err("Failed to create revwalk"))?;

        revwalk
            .push(commit.id())
            .map_err(git_err("Failed to push to revwalk"))?;

        revwalk
            .set_sorting(git2::Sort::NONE)
            .map_err(git_err("Failed to set sorting"))?;

        let counted = revwalk.take(BRANCH_COMMIT_COUNT_CAP + 1).count();
        if counted > BRANCH_COMMIT_COUNT_CAP {
            (BRANCH_COMMIT_COUNT_CAP, true)
        } else {
            (counted, false)
        }
    };

    Ok(BranchInfo {
        name: branch_name.to_string(),
        is_head,
        latest_commit_summary,
        latest_commit_author,
        latest_commit_time,
        upstream_name,
        ahead,
        behind,
        total_commits,
        total_commits_capped,
    })
}

pub fn load_tag_info(path: &Path, tag_name: &str) -> Result<TagInfo, String> {
    let repo = open_repo(path)?;

    let refname = format!("refs/tags/{tag_name}");
    let reference = repo
        .find_reference(&refname)
        .map_err(git_err("Failed to find tag reference"))?;

    let (message, tagger, tagger_time) = if let Ok(tag) = reference.peel_to_tag() {
        (
            tag.message().map(str::to_string),
            tag.tagger().and_then(|t| t.name().map(str::to_string)),
            tag.tagger().map(|t| t.when().seconds()),
        )
    } else {
        (None, None, None)
    };

    let commit = reference
        .peel_to_commit()
        .map_err(git_err("Failed to peel tag to commit"))?;

    let commit_message = commit.message().unwrap_or("");
    let commit_summary = commit_message.lines().next().unwrap_or("").to_string();
    let commit_author = commit.author().name().unwrap_or("Unknown").to_string();
    let commit_time = commit.author().when().seconds();

    let (total_commits, total_commits_capped) = {
        let mut revwalk = repo.revwalk().map_err(git_err("Failed to create revwalk"))?;

        revwalk
            .push(commit.id())
            .map_err(git_err("Failed to push to revwalk"))?;

        revwalk
            .set_sorting(git2::Sort::NONE)
            .map_err(git_err("Failed to set sorting"))?;

        let counted = revwalk.take(BRANCH_COMMIT_COUNT_CAP + 1).count();
        if counted > BRANCH_COMMIT_COUNT_CAP {
            (BRANCH_COMMIT_COUNT_CAP, true)
        } else {
            (counted, false)
        }
    };

    Ok(TagInfo {
        name: tag_name.to_string(),
        message,
        tagger,
        tagger_time,
        commit_summary,
        commit_author,
        commit_time,
        total_commits,
        total_commits_capped,
    })
}

pub fn checkout_branch(path: &Path, branch_name: &str, kind: BranchKind) -> Result<CheckoutOutcome, String> {
    let repo = open_repo(path)?;

    match kind {
        BranchKind::Local => {
            let branch = repo
                .find_branch(branch_name, git2::BranchType::Local)
                .map_err(git_err("Failed to find branch"))?;

            checkout_local_ref(&repo, &branch)?;

            Ok(CheckoutOutcome::Switched)
        }

        BranchKind::Remote => {
            let remote_branch = repo
                .find_branch(branch_name, git2::BranchType::Remote)
                .map_err(git_err("Failed to find remote branch"))?;

            let remote_ref_name = remote_branch
                .get()
                .name()
                .ok_or_else(|| "Remote branch has no valid ref name".to_string())?
                .to_string();

            let commit = remote_branch
                .get()
                .peel_to_commit()
                .map_err(git_err("Failed to peel remote branch to commit"))?;

            let local_name = strip_remote_prefix(&repo, &remote_ref_name, branch_name)?;

            match repo.find_branch(&local_name, git2::BranchType::Local) {
                Ok(local_branch) => {
                    // Branch already exists locally, do NOT touch its upstream config,
                    // the user may have configured it intentionally
                    checkout_local_ref(&repo, &local_branch)?;

                    Ok(CheckoutOutcome::SwitchedToExistingLocal)
                }

                Err(_) => {
                    let mut local_branch = repo
                        .branch(&local_name, &commit, false)
                        .map_err(git_err("Failed to create local tracking branch"))?;

                    local_branch
                        .set_upstream(Some(branch_name))
                        .map_err(git_err("Failed to set upstream"))?;

                    checkout_local_ref(&repo, &local_branch)?;

                    Ok(CheckoutOutcome::CreatedTrackingBranch)
                }
            }
        }
    }
}

pub fn checkout_detached(path: &Path, rev: &str) -> Result<(), String> {
    let repo = open_repo(path)?;

    let object = repo
        .revparse_single(rev)
        .map_err(git_err("Failed to resolve revision"))?;

    let commit = object
        .peel_to_commit()
        .map_err(git_err("Failed to resolve to commit"))?;

    let tree = commit.tree().map_err(git_err("Failed to get commit tree"))?;

    repo.checkout_tree(tree.as_object(), Some(git2::build::CheckoutBuilder::new().safe()))
        .map_err(git_err("Cannot checkout tree"))?;

    repo.set_head_detached(commit.id())
        .map_err(git_err("Failed to detach HEAD"))?;

    Ok(())
}

fn checkout_local_ref(repo: &git2::Repository, branch: &git2::Branch) -> Result<(), String> {
    let ref_name = branch
        .get()
        .name()
        .ok_or_else(|| "Branch has no valid ref name".to_string())?
        .to_string();

    let target_commit = branch
        .get()
        .peel_to_commit()
        .map_err(git_err("Failed to peel branch to commit"))?;

    let target_tree = target_commit.tree().map_err(git_err("Failed to get commit tree"))?;

    repo.checkout_tree(target_tree.as_object(), Some(git2::build::CheckoutBuilder::new().safe()))
        .map_err(git_err("Cannot switch branch"))?;

    repo.set_head(&ref_name).map_err(git_err("Failed to set HEAD"))?;

    Ok(())
}

fn strip_remote_prefix(repo: &git2::Repository, ref_name: &str, branch_name: &str) -> Result<String, String> {
    let remote_buf = repo
        .branch_remote_name(ref_name)
        .map_err(git_err("Failed to determine remote for branch"))?;

    let remote = remote_buf
        .as_str()
        .ok_or_else(|| "Remote name is not valid UTF-8".to_string())?;

    let prefix = format!("{remote}/");

    branch_name
        .strip_prefix(&prefix)
        .map(|rest| rest.to_string())
        .ok_or_else(|| format!("Remote branch '{branch_name}' does not start with expected prefix '{prefix}'"))
}

pub fn is_branch_merged(path: &Path, branch_name: &str, kind: BranchKind) -> Result<bool, String> {
    let repo = open_repo(path)?;

    let branch = repo
        .find_branch(branch_name, kind.into())
        .map_err(git_err("Failed to find branch"))?;

    let branch_commit = branch
        .get()
        .peel_to_commit()
        .map_err(git_err("Failed to peel branch to commit"))?;

    let head = repo.head().map_err(git_err("Failed to get HEAD"))?;
    let head_commit = head
        .peel_to_commit()
        .map_err(git_err("Failed to peel HEAD to commit"))?;

    let merge_base = repo
        .merge_base(branch_commit.id(), head_commit.id())
        .map_err(git_err("Failed to find merge base"))?;

    Ok(merge_base == branch_commit.id())
}

pub fn delete_branch(path: &Path, branch_name: &str, kind: BranchKind) -> Result<(), String> {
    let repo = open_repo(path)?;

    let mut branch = repo
        .find_branch(branch_name, kind.into())
        .map_err(git_err("Failed to find branch"))?;

    if branch.is_head() {
        return Err("Cannot delete the currently checked-out branch".to_string());
    }

    branch.delete().map_err(git_err("Failed to delete branch"))?;

    Ok(())
}

pub fn rename_branch(path: &Path, old_name: &str, new_name: &str) -> Result<(), String> {
    let repo = open_repo(path)?;

    let mut branch = repo
        .find_branch(old_name, git2::BranchType::Local)
        .map_err(git_err("Failed to find branch"))?;

    branch
        .rename(new_name, false)
        .map_err(git_err("Failed to rename branch"))?;

    Ok(())
}

pub fn create_branch_from(path: &Path, new_name: &str, base_ref: &str) -> Result<(), String> {
    let repo = open_repo(path)?;

    let commit = repo
        .revparse_single(base_ref)
        .map_err(git_err("Failed to find base reference"))?
        .peel_to_commit()
        .map_err(git_err("Failed to peel to commit"))?;

    repo.branch(new_name, &commit, false)
        .map_err(git_err("Failed to create branch"))?;

    Ok(())
}

pub(super) fn load_branches(repo: &git2::Repository) -> Vec<super::types::BranchEntry> {
    let mut branches = Vec::new();

    if let Ok(iter) = repo.branches(Some(git2::BranchType::Local)) {
        for branch in iter.flatten() {
            if let Ok(Some(name)) = branch.0.name() {
                let has_upstream = branch.0.upstream().is_ok();

                branches.push(super::types::BranchEntry {
                    name: name.to_string(),
                    has_upstream,
                });
            }
        }
    }

    branches
}

pub fn upstream_remote(path: &Path, branch_name: &str, kind: BranchKind) -> Result<String, String> {
    let repo = open_repo(path)?;

    let ref_name = match kind {
        BranchKind::Local => {
            let branch = repo
                .find_branch(branch_name, git2::BranchType::Local)
                .map_err(git_err("Failed to find branch"))?;

            let upstream = branch.upstream().map_err(git_err("Branch has no upstream"))?;
            upstream.get().name().ok_or("Invalid upstream ref")?.to_string()
        }

        BranchKind::Remote => format!("refs/remotes/{branch_name}"),
    };

    repo.branch_remote_name(&ref_name)
        .map_err(git_err("Failed to resolve remote"))
        .and_then(|buf| {
            buf.as_str()
                .map(str::to_string)
                .ok_or_else(|| "Invalid remote name".to_string())
        })
}

pub(super) fn load_tags(repo: &git2::Repository) -> Vec<String> {
    let mut tags = Vec::new();

    if let Ok(tag_names) = repo.tag_names(None) {
        for tag in tag_names.iter().flatten() {
            tags.push(tag.to_string());
        }
    }

    tags
}

pub fn get_commit_summary(path: &Path, target: &str) -> Result<(String, String), String> {
    let repo = open_repo(path)?;

    let obj = repo
        .revparse_single(target)
        .map_err(git_err("Failed to resolve target"))?;

    let commit = obj.peel_to_commit().map_err(git_err("Target is not a commit"))?;

    let short_oid = commit
        .as_object()
        .short_id()
        .ok()
        .and_then(|buf| buf.as_str().map(str::to_string))
        .unwrap_or_else(|| commit.id().to_string());

    let summary = commit.summary().unwrap_or("").to_string();

    Ok((short_oid, summary))
}

pub fn create_lightweight_tag(path: &Path, name: &str, target: &str) -> Result<(), String> {
    let repo = open_repo(path)?;

    let obj = repo
        .revparse_single(target)
        .map_err(git_err("Failed to resolve target"))?;

    repo.tag_lightweight(name, &obj, false)
        .map_err(git_err("Failed to create tag"))?;

    Ok(())
}

pub fn create_annotated_tag(path: &Path, name: &str, target: &str, message: &str) -> Result<(), String> {
    let repo = open_repo(path)?;

    let obj = repo
        .revparse_single(target)
        .map_err(git_err("Failed to resolve target"))?;

    let sig = repo
        .signature()
        .map_err(git_err("Failed to get signature (check user.name and user.email in git config)"))?;

    repo.tag(name, &obj, &sig, message, false)
        .map_err(git_err("Failed to create tag"))?;

    Ok(())
}

pub fn delete_tag(path: &Path, name: &str) -> Result<(), String> {
    let repo = open_repo(path)?;
    repo.tag_delete(name).map_err(git_err("Failed to delete tag"))?;
    Ok(())
}

pub fn resolve_tip_oid(path: &Path, ref_name: &str) -> Option<String> {
    let repo = open_repo(path).ok()?;
    let obj = repo.revparse_single(ref_name).ok()?;
    let commit = obj.peel_to_commit().ok()?;

    Some(commit.id().to_string())
}

pub fn build_tag_oid_map_from_repo(repo: &git2::Repository) -> HashMap<String, Vec<String>> {
    let mut map: HashMap<String, Vec<String>> = HashMap::new();

    if let Ok(tag_names) = repo.tag_names(None) {
        for tag_name in tag_names.iter().flatten() {
            let refname = format!("refs/tags/{tag_name}");

            if let Ok(reference) = repo.find_reference(&refname)
                && let Ok(commit) = reference.peel_to_commit()
            {
                map.entry(commit.id().to_string())
                    .or_default()
                    .push(tag_name.to_string());
            }
        }
    }

    map
}

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

pub fn build_branch_oid_map_from_repo(repo: &git2::Repository) -> HashMap<String, BranchRefs> {
    let mut map: HashMap<String, BranchRefs> = HashMap::new();

    if let Ok(iter) = repo.branches(None) {
        for entry in iter.flatten() {
            let (branch, kind) = entry;

            let Ok(Some(name)) = branch.name() else {
                continue;
            };

            let Ok(commit) = branch.get().peel_to_commit() else {
                continue;
            };

            let oid = commit.id().to_string();
            let refs = map.entry(oid).or_default();

            match kind {
                git2::BranchType::Local => refs.local.push(name.to_string()),

                git2::BranchType::Remote => {
                    if name.ends_with("/HEAD") {
                        continue;
                    }

                    refs.remote.push(name.to_string());
                }
            }
        }
    }

    for refs in map.values_mut() {
        refs.local.sort();
        refs.local.dedup();
        refs.remote.sort();
        refs.remote.dedup();
    }

    map
}

pub fn build_branch_oid_map(path: &Path) -> Result<HashMap<String, BranchRefs>, String> {
    let repo = open_repo(path)?;
    Ok(build_branch_oid_map_from_repo(&repo))
}

pub fn resolve_upstream_oid(path: &Path, branch_name: &str, kind: BranchKind) -> Option<UpstreamRef> {
    if kind != BranchKind::Local {
        return None;
    }

    let repo = open_repo(path).ok()?;
    let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
    let upstream = branch.upstream().ok()?;
    let name = upstream.name().ok()??.to_string();
    let oid = upstream.get().peel_to_commit().ok()?.id().to_string();

    Some(UpstreamRef { name, oid })
}

pub(super) fn load_remotes(repo: &git2::Repository) -> Vec<(String, Vec<String>)> {
    let mut remotes = Vec::new();

    if let Ok(remote_names) = repo.remotes() {
        for remote_name in remote_names.iter().flatten() {
            let mut remote_branches = Vec::new();

            if let Ok(iter) = repo.branches(Some(git2::BranchType::Remote)) {
                let prefix = format!("{}/", remote_name);

                for branch in iter.flatten() {
                    if let Ok(Some(name)) = branch.0.name()
                        && let Some(short_name) = name.strip_prefix(&prefix)
                    {
                        remote_branches.push(short_name.to_string());
                    }
                }
            }

            remotes.push((remote_name.to_string(), remote_branches));
        }
    }

    remotes
}
