/* git/merge.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::VecDeque;
use std::path::Path;

use super::todo::{self, MergeKind, MergeSource, Op, SCHEMA_VERSION, Todo, TodoContext, TodoOutcome};
use super::types::{BranchKind, MergeOutcome, MergeSettings, MergeStrategy};
use super::{git_err, open_repo};

#[cfg(test)]
mod tests;

pub(crate) enum MergeStepOutcome {
    Merged { commits: usize },
    Conflicts { conflicted_files: Vec<String> },
}

pub(crate) fn do_fast_forward(
    repo: &git2::Repository,
    head: &git2::Reference,
    target_oid: git2::Oid,
    reflog_msg: &str,
) -> Result<usize, String> {
    let local_ref_name = head.name().ok_or_else(|| "Failed to get HEAD ref name".to_string())?;
    let local_oid = head.target().ok_or_else(|| "Failed to get HEAD OID".to_string())?;

    let commits = count_commits_between(repo, local_oid, target_oid);

    let mut local_ref = repo
        .find_reference(local_ref_name)
        .map_err(git_err("Failed to find HEAD reference"))?;

    local_ref
        .set_target(target_oid, reflog_msg)
        .map_err(git_err("Failed to update branch reference"))?;

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

    repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
        .map_err(git_err("Failed to checkout after fast-forward"))?;

    Ok(commits)
}

pub(crate) fn do_merge(
    repo: &git2::Repository,
    head: &git2::Reference,
    annotated: &git2::AnnotatedCommit,
    source_oid: git2::Oid,
    message: &str,
) -> Result<MergeStepOutcome, String> {
    let local_oid = head.target().ok_or_else(|| "Failed to get HEAD OID".to_string())?;
    let commits = count_commits_between(repo, local_oid, source_oid);

    repo.merge(&[annotated], None, None).map_err(git_err("Merge failed"))?;

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

    if index.has_conflicts() {
        return Ok(MergeStepOutcome::Conflicts {
            conflicted_files: collect_conflicts(&mut index)?,
        });
    }

    let source_commit = repo
        .find_commit(source_oid)
        .map_err(git_err("Failed to find source commit"))?;

    write_tree_and_commit(repo, &mut index, head, message, &[&source_commit])?;

    Ok(MergeStepOutcome::Merged { commits })
}

fn collect_conflicts(index: &mut git2::Index) -> Result<Vec<String>, String> {
    Ok(index
        .conflicts()
        .map_err(git_err("Failed to read conflicts"))?
        .filter_map(|entry| {
            let entry = entry.ok()?;

            entry
                .our
                .as_ref()
                .or(entry.their.as_ref())
                .or(entry.ancestor.as_ref())
                .and_then(|ie| String::from_utf8(ie.path.clone()).ok())
        })
        .collect())
}

fn write_tree_and_commit(
    repo: &git2::Repository,
    index: &mut git2::Index,
    head: &git2::Reference,
    message: &str,
    extra_parents: &[&git2::Commit],
) -> Result<(), String> {
    let tree_oid = index.write_tree().map_err(git_err("Failed to write merged tree"))?;
    let tree = repo
        .find_tree(tree_oid)
        .map_err(git_err("Failed to find merged tree"))?;

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

    let mut parents: Vec<&git2::Commit> = Vec::with_capacity(1 + extra_parents.len());
    parents.push(&head_commit);
    parents.extend_from_slice(extra_parents);

    repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
        .map_err(git_err("Failed to create merge commit"))?;

    repo.cleanup_state()
        .map_err(git_err("Failed to clean up merge state"))?;

    Ok(())
}

pub(crate) fn count_commits_between(repo: &git2::Repository, from: git2::Oid, to: git2::Oid) -> usize {
    let Ok(mut revwalk) = repo.revwalk() else {
        return 0;
    };

    if revwalk.push(to).is_err() || revwalk.hide(from).is_err() {
        return 0;
    }

    revwalk.count()
}

pub fn merge_branch(path: &Path, settings: &MergeSettings, git_command: &[String]) -> Result<MergeOutcome, String> {
    let repo = open_repo(path)?;

    let head = repo.head().map_err(git_err("Failed to get HEAD"))?;
    if !head.is_branch() {
        return Err("Cannot merge: HEAD is detached".to_string());
    }

    let source_ref_name = match settings.source_kind {
        BranchKind::Local => format!("refs/heads/{}", settings.source_ref),
        BranchKind::Remote => format!("refs/remotes/{}", settings.source_ref),
    };

    let source_oid = repo
        .refname_to_id(&source_ref_name)
        .map_err(git_err(&format!("Failed to resolve '{source_ref_name}'")))?;

    let annotated = repo
        .find_annotated_commit(source_oid)
        .map_err(git_err("Failed to find source commit"))?;

    let (analysis, _) = repo
        .merge_analysis(&[&annotated])
        .map_err(git_err("Merge analysis failed"))?;

    if analysis.is_up_to_date() {
        return Ok(MergeOutcome::AlreadyUpToDate);
    }

    let local_branch = head.shorthand().unwrap_or("HEAD").to_string();
    let merge_message = format!("Merge branch '{}' into {}", settings.source_ref, local_branch);

    match settings.strategy {
        MergeStrategy::FastForwardOrMergeCommit => {
            if analysis.is_fast_forward() {
                let commits = do_fast_forward(&repo, &head, source_oid, "merge: fast-forward")?;
                return Ok(MergeOutcome::FastForward { commits });
            }

            match do_merge(&repo, &head, &annotated, source_oid, &merge_message)? {
                MergeStepOutcome::Merged { commits } => Ok(MergeOutcome::Merged { commits }),
                MergeStepOutcome::Conflicts { conflicted_files } => {
                    Ok(MergeOutcome::MergeConflicts { conflicted_files })
                }
            }
        }

        MergeStrategy::MergeCommit => match do_merge(&repo, &head, &annotated, source_oid, &merge_message)? {
            MergeStepOutcome::Merged { commits } => Ok(MergeOutcome::Merged { commits }),
            MergeStepOutcome::Conflicts { conflicted_files } => Ok(MergeOutcome::MergeConflicts { conflicted_files }),
        },

        MergeStrategy::RebaseFastForward | MergeStrategy::RebaseMergeCommit => {
            start_rebase_merge(path, &repo, git_command, &head, source_oid, settings)
        }

        MergeStrategy::Squash => {
            do_squash(&repo, &head, &annotated, source_oid, &settings.source_ref, settings.squash_message.as_deref())
        }
    }
}

fn start_rebase_merge(
    path: &Path,
    repo: &git2::Repository,
    git_command: &[String],
    head: &git2::Reference,
    source_oid: git2::Oid,
    settings: &MergeSettings,
) -> Result<MergeOutcome, String> {
    let target_oid = head.target().ok_or_else(|| "Failed to get HEAD OID".to_string())?;

    let kind = match settings.strategy {
        MergeStrategy::RebaseFastForward => MergeKind::FastForward,
        MergeStrategy::RebaseMergeCommit => MergeKind::MergeCommit,
        other => unreachable!("start_rebase_merge called with non-rebase strategy {other:?}"),
    };

    let target_branch_ref = head
        .name()
        .ok_or_else(|| "Failed to get HEAD ref name".to_string())?
        .to_string();

    let merge_base = repo
        .merge_base(target_oid, source_oid)
        .map_err(git_err("Failed to compute merge base"))?;
    let commits = count_commits_between(repo, merge_base, source_oid);

    let source_branch_ref = match settings.source_kind {
        BranchKind::Local => Some(format!("refs/heads/{}", settings.source_ref)),
        BranchKind::Remote => None,
    };

    let merge_source = match source_branch_ref.clone() {
        Some(name) => MergeSource::BranchRef { name },
        None => MergeSource::RebasedTip,
    };

    let mut ops: VecDeque<Op> = VecDeque::new();

    match source_branch_ref.as_deref() {
        Some(branch_ref) => ops.push_back(Op::Switch {
            branch_ref: branch_ref.to_string(),
        }),

        None => ops.push_back(Op::SwitchDetached {
            oid: source_oid.to_string(),
        }),
    }

    ops.push_back(Op::Rebase {
        upstream: target_oid.to_string(),
        original_head_oid: source_oid.to_string(),
        source_branch_ref: source_branch_ref.clone(),
    });

    ops.push_back(Op::Switch {
        branch_ref: target_branch_ref.clone(),
    });

    let last_op = match kind {
        MergeKind::FastForward => Op::MergeFf { source: merge_source },
        MergeKind::MergeCommit => Op::MergeNoFf { source: merge_source },
    };

    ops.push_back(last_op);

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops,
        context: TodoContext {
            original_head_ref: target_branch_ref.clone(),
            source_ref: settings.source_ref.clone(),
            commits,
            target_branch_ref,
            original_target_oid: target_oid.to_string(),
            merge_kind: kind,
            last_rebased_oid: None,
        },
    };

    todo::write(repo, &todo)?;

    match todo::run(path, git_command) {
        Ok(TodoOutcome::Done { merge, .. }) => Ok(merge),
        Ok(TodoOutcome::Paused { .. }) => Ok(MergeOutcome::RebaseConflicts),
        Ok(TodoOutcome::Stuck { reason }) => Err(reason),
        Ok(TodoOutcome::NoTodo) => Err("Todo vanished unexpectedly".to_string()),

        Err(e) => {
            todo::delete(repo);
            Err(e)
        }
    }
}

fn do_squash(
    repo: &git2::Repository,
    head: &git2::Reference,
    annotated: &git2::AnnotatedCommit,
    source_oid: git2::Oid,
    source_ref: &str,
    custom_message: Option<&str>,
) -> Result<MergeOutcome, String> {
    let local_oid = head.target().ok_or_else(|| "Failed to get HEAD OID".to_string())?;
    let commits = count_commits_between(repo, local_oid, source_oid);

    repo.merge(&[annotated], None, None).map_err(git_err("Merge failed"))?;

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

    if index.has_conflicts() {
        return Ok(MergeOutcome::MergeConflicts {
            conflicted_files: collect_conflicts(&mut index)?,
        });
    }

    let default_message = format!("Squash merge branch '{source_ref}'");
    let message = custom_message
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or(&default_message);

    write_tree_and_commit(repo, &mut index, head, message, &[])?;

    Ok(MergeOutcome::Squashed { commits })
}

pub fn read_default_merge_strategy_from_config(path: &Path) -> Option<MergeStrategy> {
    let repo = git2::Repository::open(path).ok()?;
    let config = repo.config().ok()?;
    let value = config.get_string("merge.ff").ok()?;

    Some(match value.as_str() {
        "false" => MergeStrategy::MergeCommit,
        "only" => MergeStrategy::RebaseFastForward,
        _ => MergeStrategy::FastForwardOrMergeCommit,
    })
}
