/* git/remote_ops.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::io::Read;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};

use url::Url;

use super::merge::{MergeStepOutcome, count_commits_between, do_fast_forward, do_merge};
use super::rebase::rebase_onto;
use super::types::{
    CloneProgress, PullDefaults, PullOutcome, PullSettings, PushDefaults, PushOptions, RebaseOutcome, StashPushFlags,
};
use super::{git_command, git_err, open_repo};

fn run_git(cwd: &Path, args: &[&str], git_command_argv: &[String], ssh_key: Option<&Path>) -> Result<String, String> {
    let output = git_command(Some(cwd), git_command_argv, ssh_key)
        .args(args)
        .output()
        .map_err(|e| format!("Failed to invoke git: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();

        return Err(if stderr.is_empty() {
            format!("git {} failed", args.join(" "))
        } else {
            stderr
        });
    }

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

pub fn fetch(
    path: &Path,
    remote_name: &str,
    prune: bool,
    fetch_tags: bool,
    git_command_argv: &[String],
) -> Result<(), String> {
    let ssh_key = get_ssh_key_for_remote(path, remote_name);

    let mut args: Vec<&str> = vec!["fetch"];
    if prune {
        args.push("--prune");
    }

    if fetch_tags {
        args.push("--tags");
    } else {
        args.push("--no-tags");
    }

    args.push(remote_name);

    run_git(path, &args, git_command_argv, ssh_key.as_deref())
        .map(|_| ())
        .map_err(|e| {
            forget_credentials_on_auth_failure(path, remote_name, &e);
            format!("Fetch failed for '{remote_name}': {e}")
        })
}

pub fn fetch_all(path: &Path, prune: bool, git_command_argv: &[String]) -> Result<(), String> {
    let remotes = list_remote_names(path)?;
    if remotes.is_empty() {
        return Err("No remotes configured".to_string());
    }

    let mut errors = Vec::new();
    for remote in &remotes {
        if let Err(e) = fetch(path, remote, prune, false, git_command_argv) {
            errors.push(e);
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors.join("\n"))
    }
}

pub fn get_push_defaults(path: &Path, branch_name: Option<&str>) -> Result<PushDefaults, String> {
    let repo = open_repo(path)?;

    let local_branch = match branch_name {
        Some(name) => name.to_string(),

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

            head.shorthand()
                .map(str::to_string)
                .ok_or_else(|| "Failed to get branch name".to_string())?
        }
    };

    let remotes: Vec<String> = repo
        .remotes()
        .map_err(git_err("Failed to list remotes"))?
        .iter()
        .flatten()
        .map(str::to_string)
        .collect();

    if remotes.is_empty() {
        return Err("No remotes configured".to_string());
    }

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

    let (suggested_remote, suggested_remote_branch, has_upstream) = match branch.upstream() {
        Ok(upstream) => {
            let (remote, branch) = extract_upstream_info(&repo, &upstream);
            (remote, branch, true)
        }

        Err(_) => {
            let remote = if remotes.iter().any(|r| r == "origin") {
                "origin".to_string()
            } else if let Some(first) = remotes.first() {
                first.clone()
            } else {
                return Err("No remotes configured".to_string());
            };

            (remote, local_branch.clone(), false)
        }
    };

    Ok(PushDefaults {
        remotes,
        suggested_remote,
        local_branch,
        suggested_remote_branch,
        has_upstream,
    })
}

fn extract_upstream_info(repo: &git2::Repository, upstream: &git2::Branch) -> (String, String) {
    let upstream_ref = upstream.get().name().unwrap_or("refs/remotes/origin/main");

    let remote_name = repo
        .branch_remote_name(upstream_ref)
        .ok()
        .and_then(|buf| buf.as_str().map(str::to_string))
        .unwrap_or_else(|| "origin".to_string());

    let prefix = format!("refs/remotes/{remote_name}/");
    let remote_branch = upstream_ref
        .strip_prefix(&prefix)
        .unwrap_or(upstream.get().shorthand().unwrap_or("main"))
        .to_string();

    (remote_name, remote_branch)
}

pub fn get_pull_defaults(path: &Path) -> Result<PullDefaults, 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 pull: HEAD is detached".to_string());
    }

    let local_branch = head
        .shorthand()
        .map(str::to_string)
        .ok_or_else(|| "Failed to get branch name".to_string())?;

    let remotes: Vec<String> = repo
        .remotes()
        .map_err(git_err("Failed to list remotes"))?
        .iter()
        .flatten()
        .map(str::to_string)
        .collect();

    if remotes.is_empty() {
        return Err("No remotes configured".to_string());
    }

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

    let branch_rebase_key = format!("branch.{}.rebase", local_branch);
    let rebase_default = repo
        .config()
        .ok()
        .and_then(|cfg| {
            cfg.get_bool(&branch_rebase_key)
                .or_else(|_| cfg.get_bool("pull.rebase"))
                .ok()
        })
        .unwrap_or(false);

    let (suggested_remote, suggested_remote_branch) = match branch.upstream() {
        Ok(upstream) => extract_upstream_info(&repo, &upstream),

        Err(_) => {
            let remote = if remotes.iter().any(|r| r == "origin") {
                "origin".to_string()
            } else if let Some(first) = remotes.first() {
                first.clone()
            } else {
                return Err("No remotes configured".to_string());
            };

            (remote, local_branch)
        }
    };

    let dirty = super::quick_status(path).dirty;

    Ok(PullDefaults {
        remotes,
        suggested_remote,
        suggested_remote_branch,
        rebase_default,
        dirty,
    })
}

pub fn pull(path: &Path, settings: &PullSettings, git_command_argv: &[String]) -> Result<PullOutcome, String> {
    fetch(path, &settings.remote, settings.prune, settings.fetch_tags, git_command_argv)?;

    let repo = open_repo(path)?;

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

    let remote_name = &settings.remote;
    let remote_branch = &settings.branch;

    let upstream_ref = format!("refs/remotes/{remote_name}/{remote_branch}");
    let upstream_oid = repo
        .refname_to_id(&upstream_ref)
        .map_err(git_err(&format!("Failed to resolve '{upstream_ref}'")))?;

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

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

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

    if analysis.is_fast_forward() {
        let commits = do_fast_forward(&repo, &head, upstream_oid, "pull: fast-forward")?;
        return Ok(PullOutcome::FastForward { commits });
    }

    if settings.rebase {
        let local_oid = head.target().ok_or_else(|| "Failed to get HEAD OID".to_string())?;
        let expected = count_commits_between(&repo, upstream_oid, local_oid);
        let commits_in = count_commits_between(&repo, local_oid, upstream_oid);

        drop(head);

        match rebase_onto(path, git_command_argv, upstream_oid, expected)? {
            RebaseOutcome::Completed { .. } => Ok(PullOutcome::Rebased { commits: commits_in }),
            RebaseOutcome::Paused { .. } => Ok(PullOutcome::RebaseConflicts),
        }
    } else {
        let local_branch = head.shorthand().unwrap_or("HEAD").to_string();
        let message = format!("Merge branch '{remote_branch}' of {remote_name} into {local_branch}");

        match do_merge(&repo, &head, &annotated, upstream_oid, &message)? {
            MergeStepOutcome::Merged { commits } => Ok(PullOutcome::Merged { commits }),
            MergeStepOutcome::Conflicts { conflicted_files } => Ok(PullOutcome::MergeConflicts { conflicted_files }),
        }
    }
}

pub fn pull_with_autostash(
    path: &Path,
    settings: &PullSettings,
    git_command_argv: &[String],
) -> (Result<PullOutcome, String>, bool) {
    let actually_stash = settings.stash && super::quick_status(path).dirty;

    if actually_stash
        && let Err(e) = super::stash_push(path, Some("Auto-stash before pull"), StashPushFlags::IncludeUntracked)
    {
        return (Err(format!("Failed to stash changes: {e}")), false);
    }

    let result = pull(path, settings, git_command_argv);

    let changes_stashed = if actually_stash {
        match &result {
            Ok(outcome) if !outcome.has_conflicts() => {
                if let Err(e) = super::stash_pop(path, 0) {
                    return (
                        Err(format!(
                            "Pull succeeded but failed to restore stashed changes: {e}. Your changes are saved in the \
                             stash list."
                        )),
                        true,
                    );
                }

                false
            }

            _ => true,
        }
    } else {
        false
    };

    (result, changes_stashed)
}

pub fn push(path: &Path, opts: PushOptions, git_command_argv: &[String]) -> Result<(), String> {
    let repo = open_repo(path)?;

    // Skip the branch push when local and remote-tracking refs already point at the same commit. Some servers
    // (Gitea/Forgejo) treat redundant pushes weirdly
    let local_ref = format!("refs/heads/{}", opts.local_branch);
    let remote_ref = format!("refs/remotes/{}/{}", opts.remote_name, opts.remote_branch);

    let skip_branch_push = matches!(
        (repo.refname_to_id(&local_ref), repo.refname_to_id(&remote_ref)),
        (Ok(local_oid), Ok(remote_oid)) if local_oid == remote_oid
    ) && !opts.force;

    drop(repo);

    let ssh_key = get_ssh_key_for_remote(path, &opts.remote_name);

    if !skip_branch_push {
        let refspec = format!("refs/heads/{}:refs/heads/{}", opts.local_branch, opts.remote_branch);

        let mut args: Vec<&str> = vec!["push"];
        if opts.force {
            // using `--force-with-lease` instead of `--force` is intentional; it suits most of the cases and is much
            // safer. Users may use the shell if they really need `--force`
            args.push("--force-with-lease");
        }

        if opts.set_upstream {
            args.push("--set-upstream");
        }

        args.push(&opts.remote_name);
        args.push(&refspec);

        run_git(path, &args, git_command_argv, ssh_key.as_deref()).map_err(|e| {
            forget_credentials_on_auth_failure(path, &opts.remote_name, &e);
            format!("Push failed: {e}")
        })?;
    } else if opts.set_upstream {
        // Nothing to push, but the user still asked to wire up the upstream.
        let repo = open_repo(path)?;

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

        let upstream_name = format!("{}/{}", opts.remote_name, opts.remote_branch);

        branch
            .set_upstream(Some(&upstream_name))
            .map_err(git_err("Failed to set upstream"))?;
    }

    if opts.push_tags {
        let mut args: Vec<&str> = vec!["push"];

        if opts.force {
            args.push("--force");
        }

        args.push(&opts.remote_name);
        args.push("--tags");

        run_git(path, &args, git_command_argv, ssh_key.as_deref()).map_err(|e| {
            forget_credentials_on_auth_failure(path, &opts.remote_name, &e);
            format!("Tag push failed: {e}")
        })?;
    }

    Ok(())
}

pub fn list_remote_names(path: &Path) -> Result<Vec<String>, String> {
    let repo = open_repo(path)?;
    let remotes = repo.remotes().map_err(git_err("Failed to list remotes"))?;

    Ok(remotes.iter().flatten().map(str::to_string).collect())
}

pub fn push_tag(path: &Path, tag_name: &str, remote_name: &str, git_command_argv: &[String]) -> Result<(), String> {
    let ssh_key = get_ssh_key_for_remote(path, remote_name);
    let refspec = format!("refs/tags/{tag_name}:refs/tags/{tag_name}");

    run_git(path, &["push", remote_name, &refspec], git_command_argv, ssh_key.as_deref())
        .map(|_| ())
        .map_err(|e| {
            forget_credentials_on_auth_failure(path, remote_name, &e);
            format!("Tag push failed: {e}")
        })
}

fn forget_credentials_on_auth_failure(path: &Path, remote_name: &str, err: &str) {
    if !crate::credentials::looks_like_auth_failure(err) {
        return;
    }
    let Some(url) = remote_url(path, remote_name) else {
        return;
    };
    crate::credentials::forget_blocking(&url);
}

fn remote_url(path: &Path, remote_name: &str) -> Option<String> {
    let repo = git2::Repository::open(path).ok()?;
    let remote = repo.find_remote(remote_name).ok()?;
    remote.url().map(|s| s.to_string())
}

pub fn is_ssh_url(url: &str) -> bool {
    if let Ok(parsed) = Url::parse(url) {
        return parsed.scheme() == "ssh";
    }

    if let Some(after_at) = url.split_once('@')
        && let Some((host_part, _)) = after_at.1.split_once(':')
    {
        return !host_part.contains('/');
    }

    false
}

pub fn list_ssh_remotes(path: &Path) -> Result<Vec<(String, String)>, String> {
    let repo = open_repo(path)?;
    let remotes = repo.remotes().map_err(git_err("Failed to list remotes"))?;

    let remotes = remotes
        .iter()
        .flatten()
        .filter_map(|name| {
            let remote = repo.find_remote(name).ok()?;
            let url = remote.url()?;

            is_ssh_url(url).then(|| (name.to_string(), url.to_string()))
        })
        .collect();

    Ok(remotes)
}

pub fn get_ssh_key_for_remote(path: &Path, remote_name: &str) -> Option<PathBuf> {
    let repo = git2::Repository::open(path).ok()?;
    let config = repo.config().ok()?;
    let key = format!("gitte.ssh-key.{remote_name}");
    let value = config.get_string(&key).ok()?;

    if value.is_empty() {
        return None;
    }

    Some(PathBuf::from(value))
}

pub fn clone_repository(
    url: &str,
    destination: &Path,
    git_command_argv: &[String],
    mut on_progress: impl FnMut(CloneProgress) -> bool + Send + 'static,
) -> Result<(), String> {
    let dest_str = destination.to_string_lossy().into_owned();

    let mut child = git_command(None, git_command_argv, None)
        .args(["clone", "--progress", url, &dest_str])
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| format!("Failed to invoke git clone: {e}"))?;

    let mut stderr = child
        .stderr
        .take()
        .ok_or_else(|| "Failed to capture git stderr".to_string())?;

    let mut buf = [0u8; 1024];
    let mut line = Vec::<u8>::with_capacity(256);

    let mut last_progress = CloneProgress {
        received_objects: 0,
        total_objects: 0,
        indexed_deltas: 0,
        total_deltas: 0,
        received_bytes: 0,
    };

    let mut last_emit = Instant::now()
        .checked_sub(Duration::from_secs(1))
        .unwrap_or_else(Instant::now);

    let mut error_lines = Vec::<String>::new();
    let mut cancelled = false;

    loop {
        let n = match stderr.read(&mut buf) {
            Ok(0) => break,
            Ok(n) => n,

            Err(e) => {
                let _ = child.kill();
                return Err(format!("Failed to read git stderr: {e}"));
            }
        };

        for &byte in &buf[..n] {
            // git progress uses '\r' to overwrite the same line, '\n' marks completion of a phase or a
            // regular log line.
            if byte == b'\r' || byte == b'\n' {
                if !line.is_empty() {
                    let s = String::from_utf8_lossy(&line);
                    if let Some(progress) = parse_progress_line(&s, &last_progress) {
                        last_progress = progress.clone();

                        if last_emit.elapsed() >= Duration::from_millis(100) {
                            if !on_progress(progress) {
                                cancelled = true;
                            }

                            last_emit = Instant::now();
                        }
                    } else if byte == b'\n' {
                        let trimmed = s.trim();

                        if !trimmed.is_empty() {
                            error_lines.push(trimmed.to_string());
                        }
                    }

                    line.clear();
                }
            } else {
                line.push(byte);
            }
        }

        if cancelled {
            let _ = child.kill();
            let _ = child.wait();
            return Err("Clone cancelled".to_string());
        }
    }

    // Emit final progress so the UI can settle on 100%.
    let _ = on_progress(last_progress);

    let status = child.wait().map_err(|e| format!("Failed to wait for git clone: {e}"))?;

    if status.success() {
        Ok(())
    } else {
        let msg = error_lines
            .iter()
            .rev()
            .find(|l| l.to_lowercase().contains("fatal") || l.to_lowercase().contains("error"))
            .cloned()
            .or_else(|| error_lines.last().cloned())
            .unwrap_or_else(|| "git clone failed".to_string());

        if crate::credentials::looks_like_auth_failure(&msg) {
            crate::credentials::forget_blocking(url);
        }

        Err(msg)
    }
}

fn parse_progress_line(line: &str, prev: &CloneProgress) -> Option<CloneProgress> {
    let line = line.trim_start_matches("remote:").trim();

    let (kind, rest) = if let Some(rest) = line.strip_prefix("Receiving objects:") {
        ("receiving", rest)
    } else if let Some(rest) = line.strip_prefix("Resolving deltas:") {
        ("deltas", rest)
    } else {
        return None;
    };

    // Find "(<cur>/<total>)"
    let open = rest.find('(')?;
    let close = rest[open..].find(')')?;
    let inside = &rest[open + 1..open + close];
    let (cur_s, tot_s) = inside.split_once('/')?;
    let cur: usize = cur_s.trim().parse().ok()?;
    let tot: usize = tot_s.trim().parse().ok()?;

    let mut next = prev.clone();

    match kind {
        "receiving" => {
            next.received_objects = cur;
            next.total_objects = tot;

            // Optional ", <bytes> KiB | ..." segment after the closing paren.
            let after = &rest[open + close + 1..];

            if let Some(bytes) = parse_received_bytes(after) {
                next.received_bytes = bytes;
            }
        }

        "deltas" => {
            next.indexed_deltas = cur;
            next.total_deltas = tot;
        }

        _ => return None,
    }

    Some(next)
}

fn parse_received_bytes(s: &str) -> Option<usize> {
    let s = s.trim_start_matches(',').trim();
    let amount_end = s.find(|c: char| c != '.' && !c.is_ascii_digit())?;
    let amount: f64 = s[..amount_end].parse().ok()?;
    let rest = s[amount_end..].trim_start();
    let unit_end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
    let unit = &rest[..unit_end];

    let multiplier: f64 = match unit {
        "B" => 1.0,
        "KiB" => 1024.0,
        "MiB" => 1024.0 * 1024.0,
        "GiB" => 1024.0 * 1024.0 * 1024.0,
        _ => return None,
    };

    Some((amount * multiplier) as usize)
}
