/* git/todo.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
 */

/*
 * Generic todo-list executor for multi-step git operations
 *
 * A `Todo` carries a sequence of small idempotent `Op`s plus a `TodoContext` with metadata. The executor pops ops
 * from the front as they complete and persists the shrunken file after each pop. Crash safety relies on `is_done`
 * checks per op (and on shell `git rebase` being idempotent for the `Rebase` op)
 *
 */

use std::collections::VecDeque;
use std::path::{Path, PathBuf};

use git2::Repository;
use serde::{Deserialize, Serialize};

use super::rebase::{rebase_continue, rebase_onto};
use super::types::{MergeOutcome, RebaseOutcome};
use super::{git_err, open_repo};

const TODO_FILE: &str = "gitte-todos.json";
pub(super) const SCHEMA_VERSION: u32 = 1;

#[cfg(test)]
mod tests;

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MergeKind {
    FastForward,
    MergeCommit,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MergeSource {
    BranchRef { name: String },
    RebasedTip,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Op {
    Switch {
        branch_ref: String,
    },
    SwitchDetached {
        oid: String,
    },
    Rebase {
        upstream: String,
        original_head_oid: String,
        source_branch_ref: Option<String>,
    },
    MergeFf {
        source: MergeSource,
    },
    MergeNoFf {
        source: MergeSource,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoContext {
    pub original_head_ref: String,
    pub source_ref: String,
    pub commits: usize,
    pub target_branch_ref: String,
    pub original_target_oid: String,
    pub merge_kind: MergeKind,
    pub last_rebased_oid: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
    pub schema_version: u32,
    pub ops: VecDeque<Op>,
    pub context: TodoContext,
}

#[derive(Debug)]
pub enum TodoOutcome {
    NoTodo,
    Done { merge: MergeOutcome, source_ref: String },
    Paused { has_conflicts: bool },
    Stuck { reason: String },
}

fn todo_path(repo: &Repository) -> PathBuf {
    repo.path().join(TODO_FILE)
}

pub(crate) fn write(repo: &Repository, todo: &Todo) -> Result<(), String> {
    let json = serde_json::to_string_pretty(todo).map_err(|e| format!("Failed to serialize todo: {e}"))?;
    std::fs::write(todo_path(repo), json).map_err(|e| format!("Failed to write todo file: {e}"))
}

pub(crate) fn read(repo: &Repository) -> Option<Todo> {
    let raw = std::fs::read_to_string(todo_path(repo)).ok()?;
    let todo: Todo = serde_json::from_str(&raw).ok()?;
    if todo.schema_version != SCHEMA_VERSION {
        return None;
    }
    Some(todo)
}

pub(crate) fn delete(repo: &Repository) {
    let _ = std::fs::remove_file(todo_path(repo));
}

pub fn pending_source_ref(repo_path: &Path) -> Option<String> {
    let repo = open_repo(repo_path).ok()?;
    read(&repo).map(|t| t.context.source_ref)
}

pub fn discard_if_present(repo_path: &Path) {
    if let Ok(repo) = open_repo(repo_path) {
        delete(&repo);
    }
}

pub fn run(repo_path: &Path, git_command: &[String]) -> Result<TodoOutcome, String> {
    loop {
        let repo = open_repo(repo_path)?;

        let Some(todo) = read(&repo) else {
            return Ok(TodoOutcome::NoTodo);
        };

        let Some(front) = todo.ops.front().cloned() else {
            let outcome = build_done(&todo.context);
            delete(&repo);
            return Ok(outcome);
        };

        if op_is_done(&front, &todo.context, &repo)? {
            pop_and_write(&repo, todo)?;
            continue;
        }

        match execute_op(&front, &todo.context, &repo, repo_path, git_command, Mode::Run)? {
            ExecOutcome::Continue { context_update } => {
                pop_and_write_with_update(&repo, todo, context_update)?;
            }

            ExecOutcome::Paused { has_conflicts } => {
                return Ok(TodoOutcome::Paused { has_conflicts });
            }

            ExecOutcome::Stuck { reason } => {
                return Ok(TodoOutcome::Stuck { reason });
            }
        }
    }
}

pub fn reconcile(repo_path: &Path) -> Result<TodoOutcome, String> {
    loop {
        let repo = open_repo(repo_path)?;

        let Some(todo) = read(&repo) else {
            return Ok(TodoOutcome::NoTodo);
        };

        let Some(front) = todo.ops.front().cloned() else {
            let outcome = build_done(&todo.context);
            delete(&repo);
            return Ok(outcome);
        };

        // Special handling for Rebase: never auto-execute in reconcile
        if let Op::Rebase {
            original_head_oid,
            source_branch_ref,
            ..
        } = &front
        {
            let state = repo.state();
            if matches!(
                state,
                git2::RepositoryState::Rebase
                    | git2::RepositoryState::RebaseInteractive
                    | git2::RepositoryState::RebaseMerge
            ) {
                let has_conflicts = repo
                    .index()
                    .map(|mut i| {
                        let _ = i.read(true);
                        i.has_conflicts()
                    })
                    .unwrap_or(false);

                return Ok(TodoOutcome::Paused { has_conflicts });
            }

            if state != git2::RepositoryState::Clean {
                return Ok(TodoOutcome::Paused { has_conflicts: false });
            }

            // Strict done-check: source branch tip must have moved away from original_head_oid.
            let original_oid = parse_oid(original_head_oid, "Rebase.original_head_oid")?;

            let done = match source_branch_ref.as_deref() {
                Some(branch_ref) => match repo.refname_to_id(branch_ref) {
                    Ok(tip) => tip != original_oid,
                    Err(_) => false,
                },

                None => false,
            };

            if done {
                pop_and_write(&repo, todo)?;
                continue;
            }

            return Ok(TodoOutcome::Stuck {
                reason: "Pending merge in unknown state, Continue or Abort".to_string(),
            });
        }

        if op_is_done(&front, &todo.context, &repo)? {
            pop_and_write(&repo, todo)?;
            continue;
        }

        match execute_op(&front, &todo.context, &repo, repo_path, &[], Mode::Reconcile)? {
            ExecOutcome::Continue { context_update } => {
                pop_and_write_with_update(&repo, todo, context_update)?;
            }

            ExecOutcome::Paused { has_conflicts } => {
                return Ok(TodoOutcome::Paused { has_conflicts });
            }

            ExecOutcome::Stuck { reason } => {
                return Ok(TodoOutcome::Stuck { reason });
            }
        }
    }
}

pub fn abort(repo_path: &Path, git_command: &[String]) -> Result<(), String> {
    let repo = open_repo(repo_path)?;
    let Some(todo) = read(&repo) else {
        return Ok(());
    };

    if matches!(
        repo.state(),
        git2::RepositoryState::Rebase | git2::RepositoryState::RebaseInteractive | git2::RepositoryState::RebaseMerge
    ) {
        let mut cmd = super::git_command(Some(repo_path), git_command, None);

        cmd.args(["rebase", "--abort"]);

        let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;

        if !output.status.success() {
            return Err(format!("git rebase --abort failed: {}", String::from_utf8_lossy(&output.stderr).trim()));
        }
    }

    repo.set_head(&todo.context.original_head_ref)
        .map_err(|e| format!("Failed to reattach HEAD to {}: {e}", todo.context.original_head_ref))?;

    repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
        .map_err(|e| format!("Failed to checkout HEAD: {e}"))?;

    delete(&repo);
    Ok(())
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
    Run,
    Reconcile,
}

enum ExecOutcome {
    Continue { context_update: Option<ContextUpdate> },
    Paused { has_conflicts: bool },
    Stuck { reason: String },
}

struct ContextUpdate {
    last_rebased_oid: Option<String>,
}

fn build_done(ctx: &TodoContext) -> TodoOutcome {
    let merge = match ctx.merge_kind {
        MergeKind::FastForward => MergeOutcome::Rebased { commits: ctx.commits },
        MergeKind::MergeCommit => MergeOutcome::Merged { commits: ctx.commits },
    };

    TodoOutcome::Done {
        merge,
        source_ref: ctx.source_ref.clone(),
    }
}

fn pop_and_write(repo: &Repository, mut todo: Todo) -> Result<(), String> {
    todo.ops.pop_front();
    // Even when ops is now empty, persist instead of deleting: the run/reconcile loop handles the empty-ops case
    // explicitly to return `Done`, and persisting keeps a crash here recoverable
    write(repo, &todo)
}

fn pop_and_write_with_update(repo: &Repository, mut todo: Todo, update: Option<ContextUpdate>) -> Result<(), String> {
    todo.ops.pop_front();

    if let Some(u) = update
        && let Some(oid) = u.last_rebased_oid
    {
        todo.context.last_rebased_oid = Some(oid);
    }

    write(repo, &todo)
}

fn parse_oid(s: &str, label: &str) -> Result<git2::Oid, String> {
    git2::Oid::from_str(s).map_err(|e| format!("Invalid OID for {label}: {e}"))
}

fn resolve_source_tip(source: &MergeSource, ctx: &TodoContext, repo: &Repository) -> Result<git2::Oid, String> {
    match source {
        MergeSource::BranchRef { name } => repo
            .refname_to_id(name)
            .map_err(git_err(&format!("Failed to resolve branch tip '{name}'"))),

        MergeSource::RebasedTip => {
            let oid_str = ctx
                .last_rebased_oid
                .as_deref()
                .ok_or_else(|| "RebasedTip requested but last_rebased_oid is unset".to_string())?;

            parse_oid(oid_str, "last_rebased_oid")
        }
    }
}

fn op_is_done(op: &Op, ctx: &TodoContext, repo: &Repository) -> Result<bool, String> {
    match op {
        Op::Switch { branch_ref } => {
            let detached = repo.head_detached().unwrap_or(true);

            if detached {
                return Ok(false);
            }

            let head_name = repo.head().ok().and_then(|h| h.name().map(str::to_string));

            Ok(head_name.as_deref() == Some(branch_ref.as_str()))
        }

        Op::SwitchDetached { oid } => {
            let detached = repo.head_detached().unwrap_or(false);

            if !detached {
                return Ok(false);
            }

            let target = parse_oid(oid, "SwitchDetached.oid")?;
            let head_oid = repo.head().ok().and_then(|h| h.target());

            Ok(head_oid == Some(target))
        }

        Op::Rebase { .. } => Ok(false),

        Op::MergeFf { source } => {
            let source_tip = resolve_source_tip(source, ctx, repo)?;

            let target_tip = repo
                .refname_to_id(&ctx.target_branch_ref)
                .map_err(git_err("Failed to read target branch tip"))?;

            Ok(target_tip == source_tip)
        }

        Op::MergeNoFf { source } => {
            let source_tip = resolve_source_tip(source, ctx, repo)?;
            let original_target = parse_oid(&ctx.original_target_oid, "original_target_oid")?;

            let target_tip = repo
                .refname_to_id(&ctx.target_branch_ref)
                .map_err(git_err("Failed to read target branch tip"))?;

            let commit = match repo.find_commit(target_tip) {
                Ok(c) => c,
                Err(_) => return Ok(false),
            };

            if commit.parent_count() != 2 {
                return Ok(false);
            }

            let p0 = commit.parent_id(0).ok();
            let p1 = commit.parent_id(1).ok();

            Ok(p0 == Some(original_target) && p1 == Some(source_tip))
        }
    }
}

fn execute_op(
    op: &Op,
    ctx: &TodoContext,
    repo: &Repository,
    repo_path: &Path,
    git_command: &[String],
    mode: Mode,
) -> Result<ExecOutcome, String> {
    match op {
        Op::Switch { branch_ref } => {
            repo.set_head(branch_ref).map_err(git_err("Failed to switch HEAD"))?;
            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
                .map_err(git_err("Failed to checkout"))?;

            Ok(ExecOutcome::Continue { context_update: None })
        }

        Op::SwitchDetached { oid } => {
            let target = parse_oid(oid, "SwitchDetached.oid")?;

            repo.set_head_detached(target)
                .map_err(git_err("Failed to detach HEAD"))?;
            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
                .map_err(git_err("Failed to checkout detached HEAD"))?;

            Ok(ExecOutcome::Continue { context_update: None })
        }

        Op::Rebase { upstream, .. } => {
            if mode == Mode::Reconcile {
                return Ok(ExecOutcome::Stuck {
                    reason: "Rebase op encountered in reconcile fallthrough".to_string(),
                });
            }

            let state = repo.state();
            let outcome = if matches!(
                state,
                git2::RepositoryState::Rebase
                    | git2::RepositoryState::RebaseInteractive
                    | git2::RepositoryState::RebaseMerge
            ) {
                rebase_continue(repo_path, git_command)?
            } else {
                let upstream_oid = parse_oid(upstream, "Rebase.upstream")?;
                rebase_onto(repo_path, git_command, upstream_oid, ctx.commits)?
            };

            match outcome {
                RebaseOutcome::Completed { .. } => {
                    let needs_rebased_tip = todo_uses_rebased_tip_after(repo_path)?;

                    let context_update = if needs_rebased_tip {
                        let head_oid = repo
                            .head()
                            .ok()
                            .and_then(|h| h.target())
                            .ok_or_else(|| "Failed to read HEAD after rebase".to_string())?;

                        Some(ContextUpdate {
                            last_rebased_oid: Some(head_oid.to_string()),
                        })
                    } else {
                        None
                    };

                    Ok(ExecOutcome::Continue { context_update })
                }

                RebaseOutcome::Paused { has_conflicts } => Ok(ExecOutcome::Paused { has_conflicts }),
            }
        }

        Op::MergeFf { source } => {
            let source_tip = resolve_source_tip(source, ctx, repo)?;
            let mut target_ref = repo
                .find_reference(&ctx.target_branch_ref)
                .map_err(git_err("Failed to find target branch"))?;

            target_ref
                .set_target(source_tip, "merge: rebase finalize (ff)")
                .map_err(git_err("Failed to advance target branch"))?;

            repo.set_head(&ctx.target_branch_ref)
                .map_err(git_err("Failed to attach HEAD"))?;

            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
                .map_err(git_err("Failed to checkout target branch"))?;

            Ok(ExecOutcome::Continue { context_update: None })
        }

        Op::MergeNoFf { source } => {
            let source_tip = resolve_source_tip(source, ctx, repo)?;
            let original_target = parse_oid(&ctx.original_target_oid, "original_target_oid")?;

            let original_commit = repo
                .find_commit(original_target)
                .map_err(git_err("Failed to find original target commit"))?;

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

            let tree = source_commit.tree().map_err(git_err("Failed to read source tree"))?;
            let sig = repo.signature().map_err(git_err("Failed to get signature"))?;

            let local_branch = ctx
                .target_branch_ref
                .strip_prefix("refs/heads/")
                .unwrap_or(&ctx.target_branch_ref);

            let message = format!("Merge branch '{}' into {}", ctx.source_ref, local_branch);

            let merge_oid = repo
                .commit(None, &sig, &sig, &message, &tree, &[&original_commit, &source_commit])
                .map_err(git_err("Failed to create merge commit"))?;

            let mut target_ref = repo
                .find_reference(&ctx.target_branch_ref)
                .map_err(git_err("Failed to find target branch"))?;

            target_ref
                .set_target(merge_oid, "merge: rebase+merge finalize")
                .map_err(git_err("Failed to advance target branch"))?;

            repo.set_head(&ctx.target_branch_ref)
                .map_err(git_err("Failed to attach HEAD"))?;

            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
                .map_err(git_err("Failed to checkout target branch"))?;

            Ok(ExecOutcome::Continue { context_update: None })
        }
    }
}

fn todo_uses_rebased_tip_after(repo_path: &Path) -> Result<bool, String> {
    let repo = open_repo(repo_path)?;

    let Some(todo) = read(&repo) else {
        return Ok(false);
    };

    Ok(todo.ops.iter().any(|op| {
        matches!(
            op,
            Op::MergeFf {
                source: MergeSource::RebasedTip
            } | Op::MergeNoFf {
                source: MergeSource::RebasedTip
            }
        )
    }))
}
